@academy-sdk/sdk 0.1.1 → 0.2.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 (238) hide show
  1. package/dist/bundle.js +70 -0
  2. package/dist/manifest.json +5 -0
  3. package/dist/styles.css +3307 -0
  4. package/package.json +40 -46
  5. package/src/components/atoms/Avatar.tsx +38 -0
  6. package/src/components/atoms/Badge.tsx +32 -0
  7. package/src/components/atoms/Button.tsx +48 -0
  8. package/src/components/atoms/Card.tsx +33 -0
  9. package/src/components/atoms/Input.tsx +39 -0
  10. package/src/components/atoms/ProgressBar.tsx +52 -0
  11. package/src/components/atoms/Tabs.tsx +47 -0
  12. package/{dist/components/atoms/index.d.ts → src/components/atoms/index.ts} +0 -1
  13. package/{dist/components/index.d.ts → src/components/index.ts} +7 -1
  14. package/src/components/molecules/CourseCard.tsx +215 -0
  15. package/src/components/molecules/EmptyState.tsx +23 -0
  16. package/src/components/molecules/LoadingSpinner.tsx +27 -0
  17. package/src/components/molecules/PageHeader.tsx +22 -0
  18. package/src/components/molecules/Pagination.tsx +82 -0
  19. package/src/components/molecules/SearchInput.tsx +35 -0
  20. package/{dist/components/molecules/index.d.ts → src/components/molecules/index.ts} +0 -1
  21. package/src/components/organisms/CourseSidebar.tsx +276 -0
  22. package/src/components/organisms/LearnerNavbar.tsx +129 -0
  23. package/src/components/organisms/LearnerSidebar.tsx +148 -0
  24. package/src/components/organisms/LessonBookmarks.tsx +128 -0
  25. package/src/components/organisms/LessonNotes.tsx +153 -0
  26. package/{dist/components/organisms/index.d.ts → src/components/organisms/index.ts} +0 -1
  27. package/src/components/pages/BundleDetailPage.tsx +388 -0
  28. package/src/components/pages/CatalogBundlesPage.tsx +96 -0
  29. package/src/components/pages/CatalogCoursesPage.tsx +299 -0
  30. package/src/components/pages/CourseDetailPage.tsx +582 -0
  31. package/src/components/pages/CoursePlayerPage.tsx +481 -0
  32. package/src/components/pages/CreatorProfilePage.tsx +161 -0
  33. package/src/components/pages/LearnerSettingsPage.tsx +58 -0
  34. package/src/components/pages/ManualReviewDetailPage.tsx +254 -0
  35. package/src/components/pages/ManualReviewPage.tsx +228 -0
  36. package/src/components/pages/MessagesPage.tsx +285 -0
  37. package/src/components/pages/MyLearningPage.tsx +239 -0
  38. package/src/components/pages/PaymentCancelPage.tsx +74 -0
  39. package/src/components/pages/PaymentSuccessPage.tsx +73 -0
  40. package/{dist/components/pages/index.d.ts → src/components/pages/index.ts} +0 -1
  41. package/src/components/utils.ts +6 -0
  42. package/src/contracts/components.contract.ts +89 -0
  43. package/{dist/contracts/index.d.ts → src/contracts/index.ts} +0 -1
  44. package/src/contracts/layout.contract.ts +36 -0
  45. package/src/contracts/pages.contract.ts +275 -0
  46. package/src/contracts/template.contract.ts +100 -0
  47. package/src/default-template.tsx +52 -0
  48. package/{dist/hooks/index.d.ts → src/hooks/index.ts} +15 -1
  49. package/src/hooks/sdk-context.tsx +152 -0
  50. package/src/hooks/useAiCoach.ts +27 -0
  51. package/src/hooks/useBookmarks.ts +35 -0
  52. package/{dist/hooks/useCourseSearch.d.ts → src/hooks/useCourseSearch.ts} +8 -5
  53. package/{dist/hooks/useDebounce.d.ts → src/hooks/useDebounce.ts} +8 -2
  54. package/{dist/hooks/useMyBundles.d.ts → src/hooks/useMyBundles.ts} +8 -6
  55. package/{dist/hooks/useMyCourses.d.ts → src/hooks/useMyCourses.ts} +8 -6
  56. package/src/hooks/useNotes.ts +35 -0
  57. package/src/hooks/useNotifications.ts +16 -0
  58. package/{dist/hooks/useTheme.d.ts → src/hooks/useTheme.ts} +8 -5
  59. package/src/hooks/useToast.ts +17 -0
  60. package/{dist/hooks/useUser.d.ts → src/hooks/useUser.ts} +13 -9
  61. package/src/index.ts +33 -0
  62. package/src/layouts/DefaultLayout.tsx +58 -0
  63. package/src/manifest.json +5 -0
  64. package/src/styles.css +43 -0
  65. package/src/types/ai-coach.ts +25 -0
  66. package/src/types/bookmarks.ts +20 -0
  67. package/src/types/bundle.ts +119 -0
  68. package/src/types/common.ts +24 -0
  69. package/src/types/course.ts +135 -0
  70. package/src/types/enrollment.ts +35 -0
  71. package/{dist/types/index.d.ts → src/types/index.ts} +0 -1
  72. package/src/types/lesson.ts +106 -0
  73. package/src/types/manual-review.ts +116 -0
  74. package/src/types/messaging.ts +109 -0
  75. package/src/types/notification.ts +30 -0
  76. package/src/types/payment.ts +40 -0
  77. package/src/types/progress.ts +19 -0
  78. package/src/types/rating.ts +20 -0
  79. package/src/types/search.ts +31 -0
  80. package/src/types/user.ts +16 -0
  81. package/src/utils/formatters.ts +74 -0
  82. package/src/utils/index.ts +8 -0
  83. package/dist/components/atoms/Avatar.d.ts +0 -9
  84. package/dist/components/atoms/Avatar.d.ts.map +0 -1
  85. package/dist/components/atoms/Badge.d.ts +0 -10
  86. package/dist/components/atoms/Badge.d.ts.map +0 -1
  87. package/dist/components/atoms/Button.d.ts +0 -11
  88. package/dist/components/atoms/Button.d.ts.map +0 -1
  89. package/dist/components/atoms/Card.d.ts +0 -11
  90. package/dist/components/atoms/Card.d.ts.map +0 -1
  91. package/dist/components/atoms/Input.d.ts +0 -7
  92. package/dist/components/atoms/Input.d.ts.map +0 -1
  93. package/dist/components/atoms/ProgressBar.d.ts +0 -11
  94. package/dist/components/atoms/ProgressBar.d.ts.map +0 -1
  95. package/dist/components/atoms/Tabs.d.ts +0 -16
  96. package/dist/components/atoms/Tabs.d.ts.map +0 -1
  97. package/dist/components/atoms/index.cjs +0 -318
  98. package/dist/components/atoms/index.d.ts.map +0 -1
  99. package/dist/components/atoms/index.js +0 -288
  100. package/dist/components/index.cjs +0 -1275
  101. package/dist/components/index.d.ts.map +0 -1
  102. package/dist/components/index.js +0 -1245
  103. package/dist/components/molecules/CourseCard.d.ts +0 -25
  104. package/dist/components/molecules/CourseCard.d.ts.map +0 -1
  105. package/dist/components/molecules/EmptyState.d.ts +0 -10
  106. package/dist/components/molecules/EmptyState.d.ts.map +0 -1
  107. package/dist/components/molecules/LoadingSpinner.d.ts +0 -7
  108. package/dist/components/molecules/LoadingSpinner.d.ts.map +0 -1
  109. package/dist/components/molecules/PageHeader.d.ts +0 -8
  110. package/dist/components/molecules/PageHeader.d.ts.map +0 -1
  111. package/dist/components/molecules/Pagination.d.ts +0 -13
  112. package/dist/components/molecules/Pagination.d.ts.map +0 -1
  113. package/dist/components/molecules/SearchInput.d.ts +0 -8
  114. package/dist/components/molecules/SearchInput.d.ts.map +0 -1
  115. package/dist/components/molecules/index.cjs +0 -334
  116. package/dist/components/molecules/index.d.ts.map +0 -1
  117. package/dist/components/molecules/index.js +0 -311
  118. package/dist/components/organisms/CourseSidebar.d.ts +0 -37
  119. package/dist/components/organisms/CourseSidebar.d.ts.map +0 -1
  120. package/dist/components/organisms/LearnerNavbar.d.ts +0 -8
  121. package/dist/components/organisms/LearnerNavbar.d.ts.map +0 -1
  122. package/dist/components/organisms/LearnerSidebar.d.ts +0 -16
  123. package/dist/components/organisms/LearnerSidebar.d.ts.map +0 -1
  124. package/dist/components/organisms/LessonBookmarks.d.ts +0 -8
  125. package/dist/components/organisms/LessonBookmarks.d.ts.map +0 -1
  126. package/dist/components/organisms/LessonNotes.d.ts +0 -8
  127. package/dist/components/organisms/LessonNotes.d.ts.map +0 -1
  128. package/dist/components/organisms/index.cjs +0 -855
  129. package/dist/components/organisms/index.d.ts.map +0 -1
  130. package/dist/components/organisms/index.js +0 -825
  131. package/dist/components/pages/BundleDetailPage.d.ts +0 -3
  132. package/dist/components/pages/BundleDetailPage.d.ts.map +0 -1
  133. package/dist/components/pages/CatalogBundlesPage.d.ts +0 -3
  134. package/dist/components/pages/CatalogBundlesPage.d.ts.map +0 -1
  135. package/dist/components/pages/CatalogCoursesPage.d.ts +0 -3
  136. package/dist/components/pages/CatalogCoursesPage.d.ts.map +0 -1
  137. package/dist/components/pages/CourseDetailPage.d.ts +0 -3
  138. package/dist/components/pages/CourseDetailPage.d.ts.map +0 -1
  139. package/dist/components/pages/CoursePlayerPage.d.ts +0 -8
  140. package/dist/components/pages/CoursePlayerPage.d.ts.map +0 -1
  141. package/dist/components/pages/CreatorProfilePage.d.ts +0 -3
  142. package/dist/components/pages/CreatorProfilePage.d.ts.map +0 -1
  143. package/dist/components/pages/LearnerSettingsPage.d.ts +0 -3
  144. package/dist/components/pages/LearnerSettingsPage.d.ts.map +0 -1
  145. package/dist/components/pages/ManualReviewDetailPage.d.ts +0 -3
  146. package/dist/components/pages/ManualReviewDetailPage.d.ts.map +0 -1
  147. package/dist/components/pages/ManualReviewPage.d.ts +0 -3
  148. package/dist/components/pages/ManualReviewPage.d.ts.map +0 -1
  149. package/dist/components/pages/MessagesPage.d.ts +0 -3
  150. package/dist/components/pages/MessagesPage.d.ts.map +0 -1
  151. package/dist/components/pages/MyLearningPage.d.ts +0 -3
  152. package/dist/components/pages/MyLearningPage.d.ts.map +0 -1
  153. package/dist/components/pages/PaymentCancelPage.d.ts +0 -3
  154. package/dist/components/pages/PaymentCancelPage.d.ts.map +0 -1
  155. package/dist/components/pages/PaymentSuccessPage.d.ts +0 -3
  156. package/dist/components/pages/PaymentSuccessPage.d.ts.map +0 -1
  157. package/dist/components/pages/index.cjs +0 -3306
  158. package/dist/components/pages/index.d.ts.map +0 -1
  159. package/dist/components/pages/index.js +0 -3315
  160. package/dist/components/utils.d.ts +0 -3
  161. package/dist/components/utils.d.ts.map +0 -1
  162. package/dist/contracts/components.contract.d.ts +0 -87
  163. package/dist/contracts/components.contract.d.ts.map +0 -1
  164. package/dist/contracts/index.cjs +0 -52
  165. package/dist/contracts/index.d.ts.map +0 -1
  166. package/dist/contracts/index.js +0 -29
  167. package/dist/contracts/layout.contract.d.ts +0 -35
  168. package/dist/contracts/layout.contract.d.ts.map +0 -1
  169. package/dist/contracts/pages.contract.d.ts +0 -192
  170. package/dist/contracts/pages.contract.d.ts.map +0 -1
  171. package/dist/contracts/template.contract.d.ts +0 -49
  172. package/dist/contracts/template.contract.d.ts.map +0 -1
  173. package/dist/hooks/index.cjs +0 -165
  174. package/dist/hooks/index.d.ts.map +0 -1
  175. package/dist/hooks/index.js +0 -142
  176. package/dist/hooks/sdk-context.d.ts +0 -125
  177. package/dist/hooks/sdk-context.d.ts.map +0 -1
  178. package/dist/hooks/useAiCoach.d.ts +0 -32
  179. package/dist/hooks/useAiCoach.d.ts.map +0 -1
  180. package/dist/hooks/useBookmarks.d.ts +0 -31
  181. package/dist/hooks/useBookmarks.d.ts.map +0 -1
  182. package/dist/hooks/useCourseSearch.d.ts.map +0 -1
  183. package/dist/hooks/useDebounce.d.ts.map +0 -1
  184. package/dist/hooks/useMyBundles.d.ts.map +0 -1
  185. package/dist/hooks/useMyCourses.d.ts.map +0 -1
  186. package/dist/hooks/useNotes.d.ts +0 -31
  187. package/dist/hooks/useNotes.d.ts.map +0 -1
  188. package/dist/hooks/useNotifications.d.ts +0 -19
  189. package/dist/hooks/useNotifications.d.ts.map +0 -1
  190. package/dist/hooks/useTheme.d.ts.map +0 -1
  191. package/dist/hooks/useToast.d.ts +0 -17
  192. package/dist/hooks/useToast.d.ts.map +0 -1
  193. package/dist/hooks/useUser.d.ts.map +0 -1
  194. package/dist/index.cjs +0 -630
  195. package/dist/index.d.ts +0 -17
  196. package/dist/index.d.ts.map +0 -1
  197. package/dist/index.js +0 -600
  198. package/dist/layouts/DefaultLayout.d.ts +0 -9
  199. package/dist/layouts/DefaultLayout.d.ts.map +0 -1
  200. package/dist/types/ai-coach.d.ts +0 -22
  201. package/dist/types/ai-coach.d.ts.map +0 -1
  202. package/dist/types/bookmarks.d.ts +0 -19
  203. package/dist/types/bookmarks.d.ts.map +0 -1
  204. package/dist/types/bundle.d.ts +0 -114
  205. package/dist/types/bundle.d.ts.map +0 -1
  206. package/dist/types/common.d.ts +0 -23
  207. package/dist/types/common.d.ts.map +0 -1
  208. package/dist/types/course.d.ts +0 -127
  209. package/dist/types/course.d.ts.map +0 -1
  210. package/dist/types/enrollment.d.ts +0 -34
  211. package/dist/types/enrollment.d.ts.map +0 -1
  212. package/dist/types/index.cjs +0 -18
  213. package/dist/types/index.d.ts.map +0 -1
  214. package/dist/types/index.js +0 -0
  215. package/dist/types/lesson.d.ts +0 -105
  216. package/dist/types/lesson.d.ts.map +0 -1
  217. package/dist/types/manual-review.d.ts +0 -123
  218. package/dist/types/manual-review.d.ts.map +0 -1
  219. package/dist/types/messaging.d.ts +0 -101
  220. package/dist/types/messaging.d.ts.map +0 -1
  221. package/dist/types/notification.d.ts +0 -28
  222. package/dist/types/notification.d.ts.map +0 -1
  223. package/dist/types/payment.d.ts +0 -38
  224. package/dist/types/payment.d.ts.map +0 -1
  225. package/dist/types/progress.d.ts +0 -18
  226. package/dist/types/progress.d.ts.map +0 -1
  227. package/dist/types/rating.d.ts +0 -20
  228. package/dist/types/rating.d.ts.map +0 -1
  229. package/dist/types/search.d.ts +0 -28
  230. package/dist/types/search.d.ts.map +0 -1
  231. package/dist/types/user.d.ts +0 -15
  232. package/dist/types/user.d.ts.map +0 -1
  233. package/dist/utils/formatters.d.ts +0 -25
  234. package/dist/utils/formatters.d.ts.map +0 -1
  235. package/dist/utils/index.cjs +0 -80
  236. package/dist/utils/index.d.ts +0 -2
  237. package/dist/utils/index.d.ts.map +0 -1
  238. package/dist/utils/index.js +0 -57
@@ -0,0 +1,388 @@
1
+ import { useState } from 'react';
2
+ import {
3
+ Check, Tag, X, FileText, Clock, Monitor, BookOpen,
4
+ ChevronDown, ChevronRight, Package,
5
+ } from 'lucide-react';
6
+ import type { BundleDetailPageProps } from '../../contracts/pages.contract';
7
+ import { LoadingSpinner } from '../molecules/LoadingSpinner';
8
+ import { EmptyState } from '../molecules/EmptyState';
9
+
10
+ function formatCoursePrice(price: number, isFree: boolean, currency = 'USD'): string {
11
+ if (isFree || price === 0) return 'Free';
12
+ try {
13
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price);
14
+ } catch {
15
+ return `${currency} ${price.toFixed(2)}`;
16
+ }
17
+ }
18
+
19
+ export function BundleDetailPage(props: Partial<BundleDetailPageProps>) {
20
+ const noop = (..._args: any[]) => {};
21
+ const {
22
+ bundle, isLoading = false, notFound = false, isEnrolled = false,
23
+ onEnroll = noop as any, isProcessingEnrollment = false,
24
+ paymentProviders = [], onCheckout = noop as any, isProcessingCheckout = false,
25
+ couponCode = '', onCouponCodeChange = noop, onApplyCoupon = noop as any,
26
+ appliedCoupon = null, couponError = null,
27
+ onCourseClick = noop,
28
+ } = props;
29
+
30
+ const [expandedCourses, setExpandedCourses] = useState<Set<string>>(new Set());
31
+ const [showCouponInput, setShowCouponInput] = useState(false);
32
+ const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
33
+
34
+ if (isLoading) return <LoadingSpinner className="py-20" text="Loading bundle..." />;
35
+ if (notFound || !bundle) {
36
+ return (
37
+ <EmptyState
38
+ icon={<Package className="w-16 h-16" />}
39
+ title="Bundle not found"
40
+ description="The bundle you're looking for doesn't exist."
41
+ />
42
+ );
43
+ }
44
+
45
+ const b = bundle as any;
46
+ const pricingType = b.pricingType || 'FREE';
47
+ const price = b.price ?? 0;
48
+ const currency = b.currency ?? 'USD';
49
+ const isFree = pricingType === 'FREE' || price === 0;
50
+ const totalCourses = b.courses?.length || 0;
51
+ const creatorName = b.creator?.name || 'Academy Team';
52
+ const courses: any[] = b.courses || [];
53
+
54
+ const toggleCourse = (courseId: string) => {
55
+ const next = new Set(expandedCourses);
56
+ if (next.has(courseId)) next.delete(courseId);
57
+ else next.add(courseId);
58
+ setExpandedCourses(next);
59
+ };
60
+
61
+ const getButtonText = () => {
62
+ if (isEnrolled) return 'You are enrolled';
63
+ if (isFree) return 'Enroll for free';
64
+ if (pricingType === 'SUBSCRIPTION') return 'Subscribe to access';
65
+ if (pricingType === 'INSTALLMENT') return 'Make a payment';
66
+ return 'Buy bundle';
67
+ };
68
+
69
+ const handleEnrollClick = () => {
70
+ if (isEnrolled) return;
71
+ if (isFree) {
72
+ onEnroll();
73
+ return;
74
+ }
75
+ if (paymentProviders.length === 1) {
76
+ const p = paymentProviders[0] as any;
77
+ onCheckout(p.paymentProvider || p.id);
78
+ return;
79
+ }
80
+ onEnroll();
81
+ };
82
+
83
+ // Get up to 3 course thumbnails for the collage
84
+ const thumbnails = courses
85
+ .slice(0, 3)
86
+ .map((c: any) => c.thumbnail || c.course?.thumbnail)
87
+ .filter(Boolean) as string[];
88
+
89
+ const renderEnrollmentCard = () => (
90
+ <div className="bg-white p-4 rounded-xl shadow-lg border border-gray-100">
91
+ {/* Thumbnail collage */}
92
+ {thumbnails.length > 0 ? (
93
+ <div className="relative w-full h-48 flex mb-4 rounded-lg overflow-hidden">
94
+ <div className="relative h-full w-1/2 bg-gray-100">
95
+ <img
96
+ src={thumbnails[0]}
97
+ alt="Bundle course 1"
98
+ className="w-full h-full object-cover"
99
+ onError={() => setImgErrors((p: Record<string, boolean>) => ({ ...p, t0: true }))}
100
+ />
101
+ </div>
102
+ <div className="flex flex-col w-1/2 h-full">
103
+ <div className={`relative w-full overflow-hidden bg-gray-100 ${thumbnails.length > 1 ? 'h-1/2' : 'h-full'}`}>
104
+ {thumbnails[1] && (
105
+ <img
106
+ src={thumbnails[1]}
107
+ alt="Bundle course 2"
108
+ className="w-full h-full object-cover"
109
+ onError={() => setImgErrors((p: Record<string, boolean>) => ({ ...p, t1: true }))}
110
+ />
111
+ )}
112
+ </div>
113
+ {thumbnails.length > 2 && (
114
+ <div className="relative w-full h-1/2 overflow-hidden bg-gray-100">
115
+ <img
116
+ src={thumbnails[2]}
117
+ alt="Bundle course 3"
118
+ className="w-full h-full object-cover"
119
+ onError={() => setImgErrors((p: Record<string, boolean>) => ({ ...p, t2: true }))}
120
+ />
121
+ </div>
122
+ )}
123
+ </div>
124
+ </div>
125
+ ) : (
126
+ <div className="w-full h-48 bg-gray-100 rounded-lg mb-4 flex items-center justify-center">
127
+ <Package className="w-16 h-16 text-gray-300" />
128
+ </div>
129
+ )}
130
+
131
+ {/* Enrolled status */}
132
+ {isEnrolled && (
133
+ <div className="mb-4 flex items-center gap-2 text-green-600">
134
+ <Check className="h-5 w-5" />
135
+ <span className="font-semibold text-sm">Enrolled in this bundle</span>
136
+ </div>
137
+ )}
138
+
139
+ {/* Price */}
140
+ <div className="text-center mb-4">
141
+ <div className="text-2xl font-bold text-gray-900">
142
+ {formatCoursePrice(price, isFree, currency)}
143
+ {pricingType === 'SUBSCRIPTION' && (
144
+ <span className="text-lg font-normal text-gray-500"> / month</span>
145
+ )}
146
+ </div>
147
+ {!isFree && !isEnrolled && (
148
+ <p className="text-sm text-[#49BBBD] font-medium mt-1">Bundle Deal</p>
149
+ )}
150
+ </div>
151
+
152
+ {/* Main action button */}
153
+ {!isEnrolled && (
154
+ <button
155
+ onClick={handleEnrollClick}
156
+ disabled={isProcessingEnrollment || isProcessingCheckout}
157
+ className="w-full bg-theme-accent-primary hover:bg-[#3da5a7] text-white font-semibold py-3 px-6 rounded-md transition-colors mb-4 cursor-pointer disabled:opacity-50"
158
+ >
159
+ {isProcessingEnrollment || isProcessingCheckout ? 'Processing...' : getButtonText()}
160
+ </button>
161
+ )}
162
+
163
+ {/* Multiple payment providers */}
164
+ {!isEnrolled && !isFree && paymentProviders.length > 1 &&
165
+ paymentProviders.map((provider: any) => (
166
+ <button
167
+ key={provider.paymentProvider || provider.id}
168
+ onClick={() => onCheckout(provider.paymentProvider || provider.id)}
169
+ disabled={isProcessingCheckout}
170
+ className="w-full bg-theme-accent-primary hover:bg-[#3da5a7] text-white font-semibold py-3 px-6 rounded-md transition-colors mb-2 cursor-pointer disabled:opacity-50"
171
+ >
172
+ Pay with {provider.paymentProvider}
173
+ </button>
174
+ ))
175
+ }
176
+
177
+ {/* Apply Coupon */}
178
+ {!isFree && !isEnrolled && (
179
+ <div className="mb-6">
180
+ {appliedCoupon ? (
181
+ <div className="p-3 bg-theme-accent-primary/10 border border-[#49BBBD]/20 rounded-lg">
182
+ <div className="flex items-center justify-between">
183
+ <div className="flex items-center gap-2">
184
+ <Tag className="h-4 w-4 text-[#49BBBD]" />
185
+ <span className="text-sm font-medium text-[#49BBBD]">
186
+ Coupon &quot;{(appliedCoupon as any).code}&quot; applied
187
+ </span>
188
+ </div>
189
+ <button
190
+ onClick={() => onCouponCodeChange('')}
191
+ className="text-[#49BBBD] hover:text-[#3da5a7] transition-colors cursor-pointer"
192
+ >
193
+ <X className="h-4 w-4" />
194
+ </button>
195
+ </div>
196
+ </div>
197
+ ) : showCouponInput ? (
198
+ <div className="space-y-2">
199
+ <div className="flex gap-2">
200
+ <input
201
+ type="text"
202
+ placeholder="Enter coupon code"
203
+ value={couponCode}
204
+ onChange={(e) => onCouponCodeChange(e.target.value.toUpperCase())}
205
+ className={`flex-1 text-sm border rounded-md px-3 py-2 focus:outline-none focus:border-[#49BBBD] ${couponError ? 'border-red-500' : 'border-gray-300'}`}
206
+ />
207
+ <button
208
+ onClick={onApplyCoupon as any}
209
+ disabled={!couponCode}
210
+ className="px-3 py-2 text-sm bg-theme-accent-primary text-white rounded-md hover:bg-[#3da5a7] disabled:opacity-50 cursor-pointer"
211
+ >
212
+ Apply
213
+ </button>
214
+ </div>
215
+ {couponError && <p className="text-xs text-red-600">{couponError}</p>}
216
+ </div>
217
+ ) : (
218
+ <a
219
+ href="#"
220
+ onClick={(e) => { e.preventDefault(); setShowCouponInput(true); }}
221
+ className="block text-center text-sm font-medium text-[#49BBBD] hover:text-[#3da5a7] hover:underline cursor-pointer"
222
+ >
223
+ Apply Coupon
224
+ </a>
225
+ )}
226
+ </div>
227
+ )}
228
+
229
+ {/* Bundle included */}
230
+ <div className="border-t border-gray-200 pt-4">
231
+ <h3 className="font-semibold text-gray-900 mb-3 text-sm">This bundle included</h3>
232
+ <div className="space-y-2.5">
233
+ <div className="flex items-center gap-2.5 text-gray-700">
234
+ <div className="w-4 h-4 rounded-full bg-theme-accent-primary flex items-center justify-center flex-shrink-0">
235
+ <FileText className="h-2.5 w-2.5 text-white" />
236
+ </div>
237
+ <span className="text-sm">{totalCourses} courses</span>
238
+ </div>
239
+ <div className="flex items-center gap-2.5 text-gray-700">
240
+ <div className="w-4 h-4 rounded-full bg-theme-accent-primary flex items-center justify-center flex-shrink-0">
241
+ <Clock className="h-2.5 w-2.5 text-white" />
242
+ </div>
243
+ <span className="text-sm">Self-paced learning</span>
244
+ </div>
245
+ <div className="flex items-center gap-2.5 text-gray-700">
246
+ <div className="w-4 h-4 rounded-full bg-theme-accent-primary flex items-center justify-center flex-shrink-0">
247
+ <Monitor className="h-2.5 w-2.5 text-white" />
248
+ </div>
249
+ <span className="text-sm">Access on mobile and desktop</span>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ );
255
+
256
+ return (
257
+ <div className="-mx-4 sm:-mx-4 md:-mx-4 lg:-mx-6 xl:-mx-6">
258
+ {/* Full-width Dark Header */}
259
+ <div className="relative bg-[#1a2332] text-white px-4 lg:px-8 py-8 lg:py-12 mb-8">
260
+ {/* Bundle info */}
261
+ <div className="max-w-7xl lg:pr-110">
262
+ <h1 className="text-3xl lg:text-4xl font-bold mb-2">{b.title}</h1>
263
+ {b.description && (
264
+ <p className="text-base lg:text-lg text-gray-300 mb-4 leading-relaxed">{b.description}</p>
265
+ )}
266
+ <div className="flex items-center gap-3 mt-4 lg:mt-6 mb-6 lg:mb-0">
267
+ <div className="w-10 h-10 rounded-full bg-theme-accent-primary flex items-center justify-center text-white font-semibold">
268
+ {creatorName.charAt(0).toUpperCase()}
269
+ </div>
270
+ <span className="text-sm">{creatorName}</span>
271
+ </div>
272
+ </div>
273
+
274
+ {/* Mobile enrollment card — inside dark header */}
275
+ <div className="lg:hidden mt-6">
276
+ {renderEnrollmentCard()}
277
+ </div>
278
+
279
+ {/* Desktop enrollment card — absolute positioned */}
280
+ <div className="hidden lg:block absolute right-30 top-15 w-80 z-10">
281
+ {renderEnrollmentCard()}
282
+ </div>
283
+ </div>
284
+
285
+ {/* Content */}
286
+ <div className="px-4 lg:px-6">
287
+ {/* About This Bundle */}
288
+ <div className="lg:pr-110 pr-4 mb-8 pb-8 border-b border-gray-300">
289
+ <h2 className="text-xl font-bold text-gray-900 mb-4">About This Bundle</h2>
290
+ <div className="text-gray-700 text-sm leading-relaxed">
291
+ {b.description || 'This comprehensive bundle includes multiple courses designed to help you master your learning goals.'}
292
+ </div>
293
+ </div>
294
+
295
+ {/* Included Courses */}
296
+ <div className="mb-8">
297
+ <h2 className="text-xl font-bold text-gray-900 mb-4">
298
+ Included Courses{totalCourses > 0 ? ` (${totalCourses})` : ''}
299
+ </h2>
300
+
301
+ {courses.length === 0 ? (
302
+ <div className="text-center py-8 text-gray-500">
303
+ No courses included in this bundle yet.
304
+ </div>
305
+ ) : (
306
+ <div className="space-y-1 lg:max-w-[70%] md:max-w-[100%]">
307
+ {courses
308
+ .sort((a: any, b: any) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
309
+ .map((bundleCourse: any, index: number) => {
310
+ const course = bundleCourse.course || bundleCourse;
311
+ const courseId = bundleCourse.courseId || bundleCourse.id || course.id;
312
+ const courseThumbnail = course.thumbnail || bundleCourse.thumbnail;
313
+ const isExpanded = expandedCourses.has(courseId);
314
+ const isCompleted = bundleCourse.isCompleted;
315
+
316
+ return (
317
+ <div key={courseId} className="border border-gray-200 rounded overflow-hidden">
318
+ <button
319
+ onClick={() => toggleCourse(courseId)}
320
+ className="w-full flex items-center justify-between p-3 hover:bg-gray-50 transition-colors text-left cursor-pointer"
321
+ >
322
+ <div className="flex items-center gap-3">
323
+ {isExpanded ? (
324
+ <ChevronDown className="h-4 w-4 text-gray-600 flex-shrink-0" />
325
+ ) : (
326
+ <ChevronRight className="h-4 w-4 text-gray-600 flex-shrink-0" />
327
+ )}
328
+ {/* Course number */}
329
+ <div className="w-8 h-8 rounded-full bg-purple-100 text-purple-700 flex items-center justify-center font-bold text-sm flex-shrink-0">
330
+ {index + 1}
331
+ </div>
332
+ {/* Thumbnail */}
333
+ {courseThumbnail && !imgErrors[`c-${courseId}`] && (
334
+ <img
335
+ src={courseThumbnail}
336
+ alt={course.title}
337
+ className="w-12 h-8 object-cover rounded flex-shrink-0"
338
+ onError={() => setImgErrors((p: Record<string, boolean>) => ({ ...p, [`c-${courseId}`]: true }))}
339
+ />
340
+ )}
341
+ <div className="flex-1 min-w-0">
342
+ <span className="text-sm font-medium text-gray-900 truncate block">
343
+ {course.title || bundleCourse.title || 'Course'}
344
+ </span>
345
+ {course.summary && (
346
+ <p className="text-xs text-gray-500 truncate">{course.summary}</p>
347
+ )}
348
+ </div>
349
+ </div>
350
+ <div className="flex items-center gap-2 flex-shrink-0 ml-2">
351
+ {isCompleted && (
352
+ <Check className="h-4 w-4 text-green-600" />
353
+ )}
354
+ </div>
355
+ </button>
356
+
357
+ {isExpanded && (
358
+ <div className="px-4 py-3 bg-gray-50 border-t border-gray-200">
359
+ <div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
360
+ <div className="flex items-center gap-1">
361
+ <BookOpen className="h-4 w-4" />
362
+ <span>Course {index + 1}</span>
363
+ </div>
364
+ {course.status && (
365
+ <div className="flex items-center gap-1">
366
+ <div className={`w-2 h-2 rounded-full ${course.status === 'PUBLISHED' ? 'bg-green-500' : 'bg-gray-400'}`} />
367
+ <span className="capitalize">{course.status.toLowerCase()}</span>
368
+ </div>
369
+ )}
370
+ </div>
371
+ <button
372
+ onClick={() => onCourseClick(courseId, bundleCourse.enrollmentId)}
373
+ className="text-sm font-medium text-[#49BBBD] hover:text-[#3da5a7] hover:underline cursor-pointer"
374
+ >
375
+ {isEnrolled ? 'Go to course →' : 'View course →'}
376
+ </button>
377
+ </div>
378
+ )}
379
+ </div>
380
+ );
381
+ })}
382
+ </div>
383
+ )}
384
+ </div>
385
+ </div>
386
+ </div>
387
+ );
388
+ }
@@ -0,0 +1,96 @@
1
+ import { Search, X } from 'lucide-react';
2
+ import type { CatalogBundlesPageProps } from '../../contracts/pages.contract';
3
+ import { Pagination } from '../molecules/Pagination';
4
+ import { CourseCard } from '../molecules/CourseCard';
5
+
6
+ export function CatalogBundlesPage(props: Partial<CatalogBundlesPageProps>) {
7
+ const noop = (..._args: any[]) => {};
8
+ const { bundles = [], meta, loading = false, searchQuery = '', page = 1, onSearchChange = noop, onPageChange = noop, onBundleClick = noop } = props;
9
+
10
+ return (
11
+ <div className="space-y-6">
12
+ {/* Search Bar */}
13
+ <div className="relative">
14
+ <Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-theme-text-secondary z-10" />
15
+ <input
16
+ type="text"
17
+ placeholder="Search bundles..."
18
+ value={searchQuery}
19
+ onChange={(e) => onSearchChange(e.target.value)}
20
+ className="w-full rounded-lg border border-theme-border-primary bg-theme-bg-secondary pl-10 pr-10 py-2 text-sm text-theme-text-primary placeholder:text-theme-text-secondary focus:outline-none focus:ring-2 focus:ring-theme-accent-primary/30 focus:border-theme-accent-primary transition-all"
21
+ />
22
+ {searchQuery && (
23
+ <button
24
+ onClick={() => onSearchChange('')}
25
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-theme-text-secondary hover:text-theme-text-primary transition-colors"
26
+ >
27
+ <X className="h-5 w-5" />
28
+ </button>
29
+ )}
30
+ </div>
31
+
32
+ {/* Content */}
33
+ {loading ? (
34
+ <div className="flex flex-col items-center justify-center py-16">
35
+ <div className="h-12 w-12 animate-spin rounded-full border-4 border-theme-border-primary border-t-theme-accent-primary"></div>
36
+ <p className="mt-4 text-theme-text-secondary">Loading bundles...</p>
37
+ </div>
38
+ ) : bundles.length === 0 ? (
39
+ <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-theme-border-primary bg-theme-bg-secondary py-16 text-center">
40
+ <div className="text-4xl mb-4">📦</div>
41
+ <h3 className="text-xl font-semibold text-theme-text-primary">No bundles found</h3>
42
+ <p className="mt-2 max-w-md text-theme-text-secondary">
43
+ Try adjusting your search query or check back later for new bundles
44
+ </p>
45
+ </div>
46
+ ) : (
47
+ <>
48
+ <div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
49
+ {bundles.map((bundle: any) => {
50
+ const priceValue = bundle.price !== undefined && bundle.price !== null ? bundle.price : (bundle.settings?.price || 0);
51
+ const pricingType = bundle.pricingType || bundle.settings?.pricingType;
52
+ const isFree = pricingType === 'FREE' || !priceValue || priceValue === 0;
53
+ const thumbnails: string[] = bundle.courses && bundle.courses.length > 0
54
+ ? bundle.courses
55
+ .sort((a: any, b: any) => a.orderIndex - b.orderIndex)
56
+ .slice(0, 3)
57
+ .map((c: any) => c.thumbnail)
58
+ : (bundle.thumbnails || []);
59
+ return (
60
+ <CourseCard
61
+ key={bundle.id}
62
+ id={bundle.id}
63
+ title={bundle.title || ''}
64
+ thumbnail={thumbnails[0]}
65
+ thumbnails={thumbnails}
66
+ totalLessons={bundle.totalCourses || bundle.courses?.length || 0}
67
+ progress={0}
68
+ showProgress={false}
69
+ isFree={isFree}
70
+ price={priceValue}
71
+ showPrice={true}
72
+ currency={bundle.currency}
73
+ description={bundle.description || ''}
74
+ isBundle={true}
75
+ onClick={() => onBundleClick(bundle.id)}
76
+ />
77
+ );
78
+ })}
79
+ </div>
80
+ {meta && meta.totalPages > 1 && (
81
+ <Pagination
82
+ currentPage={page}
83
+ totalPages={meta.totalPages}
84
+ total={meta.total}
85
+ pageSize={meta.limit || 12}
86
+ hasNextPage={page < meta.totalPages}
87
+ hasPreviousPage={page > 1}
88
+ onPageChange={onPageChange}
89
+ />
90
+ )}
91
+ </>
92
+
93
+ )}
94
+ </div>
95
+ );
96
+ }