@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.
- package/dist/bundle.js +70 -0
- package/dist/manifest.json +5 -0
- package/dist/styles.css +3307 -0
- package/package.json +40 -46
- package/src/components/atoms/Avatar.tsx +38 -0
- package/src/components/atoms/Badge.tsx +32 -0
- package/src/components/atoms/Button.tsx +48 -0
- package/src/components/atoms/Card.tsx +33 -0
- package/src/components/atoms/Input.tsx +39 -0
- package/src/components/atoms/ProgressBar.tsx +52 -0
- package/src/components/atoms/Tabs.tsx +47 -0
- package/{dist/components/atoms/index.d.ts → src/components/atoms/index.ts} +0 -1
- package/{dist/components/index.d.ts → src/components/index.ts} +7 -1
- package/src/components/molecules/CourseCard.tsx +215 -0
- package/src/components/molecules/EmptyState.tsx +23 -0
- package/src/components/molecules/LoadingSpinner.tsx +27 -0
- package/src/components/molecules/PageHeader.tsx +22 -0
- package/src/components/molecules/Pagination.tsx +82 -0
- package/src/components/molecules/SearchInput.tsx +35 -0
- package/{dist/components/molecules/index.d.ts → src/components/molecules/index.ts} +0 -1
- package/src/components/organisms/CourseSidebar.tsx +276 -0
- package/src/components/organisms/LearnerNavbar.tsx +129 -0
- package/src/components/organisms/LearnerSidebar.tsx +148 -0
- package/src/components/organisms/LessonBookmarks.tsx +128 -0
- package/src/components/organisms/LessonNotes.tsx +153 -0
- package/{dist/components/organisms/index.d.ts → src/components/organisms/index.ts} +0 -1
- package/src/components/pages/BundleDetailPage.tsx +388 -0
- package/src/components/pages/CatalogBundlesPage.tsx +96 -0
- package/src/components/pages/CatalogCoursesPage.tsx +299 -0
- package/src/components/pages/CourseDetailPage.tsx +582 -0
- package/src/components/pages/CoursePlayerPage.tsx +481 -0
- package/src/components/pages/CreatorProfilePage.tsx +161 -0
- package/src/components/pages/LearnerSettingsPage.tsx +58 -0
- package/src/components/pages/ManualReviewDetailPage.tsx +254 -0
- package/src/components/pages/ManualReviewPage.tsx +228 -0
- package/src/components/pages/MessagesPage.tsx +285 -0
- package/src/components/pages/MyLearningPage.tsx +239 -0
- package/src/components/pages/PaymentCancelPage.tsx +74 -0
- package/src/components/pages/PaymentSuccessPage.tsx +73 -0
- package/{dist/components/pages/index.d.ts → src/components/pages/index.ts} +0 -1
- package/src/components/utils.ts +6 -0
- package/src/contracts/components.contract.ts +89 -0
- package/{dist/contracts/index.d.ts → src/contracts/index.ts} +0 -1
- package/src/contracts/layout.contract.ts +36 -0
- package/src/contracts/pages.contract.ts +275 -0
- package/src/contracts/template.contract.ts +100 -0
- package/src/default-template.tsx +52 -0
- package/{dist/hooks/index.d.ts → src/hooks/index.ts} +15 -1
- package/src/hooks/sdk-context.tsx +152 -0
- package/src/hooks/useAiCoach.ts +27 -0
- package/src/hooks/useBookmarks.ts +35 -0
- package/{dist/hooks/useCourseSearch.d.ts → src/hooks/useCourseSearch.ts} +8 -5
- package/{dist/hooks/useDebounce.d.ts → src/hooks/useDebounce.ts} +8 -2
- package/{dist/hooks/useMyBundles.d.ts → src/hooks/useMyBundles.ts} +8 -6
- package/{dist/hooks/useMyCourses.d.ts → src/hooks/useMyCourses.ts} +8 -6
- package/src/hooks/useNotes.ts +35 -0
- package/src/hooks/useNotifications.ts +16 -0
- package/{dist/hooks/useTheme.d.ts → src/hooks/useTheme.ts} +8 -5
- package/src/hooks/useToast.ts +17 -0
- package/{dist/hooks/useUser.d.ts → src/hooks/useUser.ts} +13 -9
- package/src/index.ts +33 -0
- package/src/layouts/DefaultLayout.tsx +58 -0
- package/src/manifest.json +5 -0
- package/src/styles.css +43 -0
- package/src/types/ai-coach.ts +25 -0
- package/src/types/bookmarks.ts +20 -0
- package/src/types/bundle.ts +119 -0
- package/src/types/common.ts +24 -0
- package/src/types/course.ts +135 -0
- package/src/types/enrollment.ts +35 -0
- package/{dist/types/index.d.ts → src/types/index.ts} +0 -1
- package/src/types/lesson.ts +106 -0
- package/src/types/manual-review.ts +116 -0
- package/src/types/messaging.ts +109 -0
- package/src/types/notification.ts +30 -0
- package/src/types/payment.ts +40 -0
- package/src/types/progress.ts +19 -0
- package/src/types/rating.ts +20 -0
- package/src/types/search.ts +31 -0
- package/src/types/user.ts +16 -0
- package/src/utils/formatters.ts +74 -0
- package/src/utils/index.ts +8 -0
- package/dist/components/atoms/Avatar.d.ts +0 -9
- package/dist/components/atoms/Avatar.d.ts.map +0 -1
- package/dist/components/atoms/Badge.d.ts +0 -10
- package/dist/components/atoms/Badge.d.ts.map +0 -1
- package/dist/components/atoms/Button.d.ts +0 -11
- package/dist/components/atoms/Button.d.ts.map +0 -1
- package/dist/components/atoms/Card.d.ts +0 -11
- package/dist/components/atoms/Card.d.ts.map +0 -1
- package/dist/components/atoms/Input.d.ts +0 -7
- package/dist/components/atoms/Input.d.ts.map +0 -1
- package/dist/components/atoms/ProgressBar.d.ts +0 -11
- package/dist/components/atoms/ProgressBar.d.ts.map +0 -1
- package/dist/components/atoms/Tabs.d.ts +0 -16
- package/dist/components/atoms/Tabs.d.ts.map +0 -1
- package/dist/components/atoms/index.cjs +0 -318
- package/dist/components/atoms/index.d.ts.map +0 -1
- package/dist/components/atoms/index.js +0 -288
- package/dist/components/index.cjs +0 -1275
- package/dist/components/index.d.ts.map +0 -1
- package/dist/components/index.js +0 -1245
- package/dist/components/molecules/CourseCard.d.ts +0 -25
- package/dist/components/molecules/CourseCard.d.ts.map +0 -1
- package/dist/components/molecules/EmptyState.d.ts +0 -10
- package/dist/components/molecules/EmptyState.d.ts.map +0 -1
- package/dist/components/molecules/LoadingSpinner.d.ts +0 -7
- package/dist/components/molecules/LoadingSpinner.d.ts.map +0 -1
- package/dist/components/molecules/PageHeader.d.ts +0 -8
- package/dist/components/molecules/PageHeader.d.ts.map +0 -1
- package/dist/components/molecules/Pagination.d.ts +0 -13
- package/dist/components/molecules/Pagination.d.ts.map +0 -1
- package/dist/components/molecules/SearchInput.d.ts +0 -8
- package/dist/components/molecules/SearchInput.d.ts.map +0 -1
- package/dist/components/molecules/index.cjs +0 -334
- package/dist/components/molecules/index.d.ts.map +0 -1
- package/dist/components/molecules/index.js +0 -311
- package/dist/components/organisms/CourseSidebar.d.ts +0 -37
- package/dist/components/organisms/CourseSidebar.d.ts.map +0 -1
- package/dist/components/organisms/LearnerNavbar.d.ts +0 -8
- package/dist/components/organisms/LearnerNavbar.d.ts.map +0 -1
- package/dist/components/organisms/LearnerSidebar.d.ts +0 -16
- package/dist/components/organisms/LearnerSidebar.d.ts.map +0 -1
- package/dist/components/organisms/LessonBookmarks.d.ts +0 -8
- package/dist/components/organisms/LessonBookmarks.d.ts.map +0 -1
- package/dist/components/organisms/LessonNotes.d.ts +0 -8
- package/dist/components/organisms/LessonNotes.d.ts.map +0 -1
- package/dist/components/organisms/index.cjs +0 -855
- package/dist/components/organisms/index.d.ts.map +0 -1
- package/dist/components/organisms/index.js +0 -825
- package/dist/components/pages/BundleDetailPage.d.ts +0 -3
- package/dist/components/pages/BundleDetailPage.d.ts.map +0 -1
- package/dist/components/pages/CatalogBundlesPage.d.ts +0 -3
- package/dist/components/pages/CatalogBundlesPage.d.ts.map +0 -1
- package/dist/components/pages/CatalogCoursesPage.d.ts +0 -3
- package/dist/components/pages/CatalogCoursesPage.d.ts.map +0 -1
- package/dist/components/pages/CourseDetailPage.d.ts +0 -3
- package/dist/components/pages/CourseDetailPage.d.ts.map +0 -1
- package/dist/components/pages/CoursePlayerPage.d.ts +0 -8
- package/dist/components/pages/CoursePlayerPage.d.ts.map +0 -1
- package/dist/components/pages/CreatorProfilePage.d.ts +0 -3
- package/dist/components/pages/CreatorProfilePage.d.ts.map +0 -1
- package/dist/components/pages/LearnerSettingsPage.d.ts +0 -3
- package/dist/components/pages/LearnerSettingsPage.d.ts.map +0 -1
- package/dist/components/pages/ManualReviewDetailPage.d.ts +0 -3
- package/dist/components/pages/ManualReviewDetailPage.d.ts.map +0 -1
- package/dist/components/pages/ManualReviewPage.d.ts +0 -3
- package/dist/components/pages/ManualReviewPage.d.ts.map +0 -1
- package/dist/components/pages/MessagesPage.d.ts +0 -3
- package/dist/components/pages/MessagesPage.d.ts.map +0 -1
- package/dist/components/pages/MyLearningPage.d.ts +0 -3
- package/dist/components/pages/MyLearningPage.d.ts.map +0 -1
- package/dist/components/pages/PaymentCancelPage.d.ts +0 -3
- package/dist/components/pages/PaymentCancelPage.d.ts.map +0 -1
- package/dist/components/pages/PaymentSuccessPage.d.ts +0 -3
- package/dist/components/pages/PaymentSuccessPage.d.ts.map +0 -1
- package/dist/components/pages/index.cjs +0 -3306
- package/dist/components/pages/index.d.ts.map +0 -1
- package/dist/components/pages/index.js +0 -3315
- package/dist/components/utils.d.ts +0 -3
- package/dist/components/utils.d.ts.map +0 -1
- package/dist/contracts/components.contract.d.ts +0 -87
- package/dist/contracts/components.contract.d.ts.map +0 -1
- package/dist/contracts/index.cjs +0 -52
- package/dist/contracts/index.d.ts.map +0 -1
- package/dist/contracts/index.js +0 -29
- package/dist/contracts/layout.contract.d.ts +0 -35
- package/dist/contracts/layout.contract.d.ts.map +0 -1
- package/dist/contracts/pages.contract.d.ts +0 -192
- package/dist/contracts/pages.contract.d.ts.map +0 -1
- package/dist/contracts/template.contract.d.ts +0 -49
- package/dist/contracts/template.contract.d.ts.map +0 -1
- package/dist/hooks/index.cjs +0 -165
- package/dist/hooks/index.d.ts.map +0 -1
- package/dist/hooks/index.js +0 -142
- package/dist/hooks/sdk-context.d.ts +0 -125
- package/dist/hooks/sdk-context.d.ts.map +0 -1
- package/dist/hooks/useAiCoach.d.ts +0 -32
- package/dist/hooks/useAiCoach.d.ts.map +0 -1
- package/dist/hooks/useBookmarks.d.ts +0 -31
- package/dist/hooks/useBookmarks.d.ts.map +0 -1
- package/dist/hooks/useCourseSearch.d.ts.map +0 -1
- package/dist/hooks/useDebounce.d.ts.map +0 -1
- package/dist/hooks/useMyBundles.d.ts.map +0 -1
- package/dist/hooks/useMyCourses.d.ts.map +0 -1
- package/dist/hooks/useNotes.d.ts +0 -31
- package/dist/hooks/useNotes.d.ts.map +0 -1
- package/dist/hooks/useNotifications.d.ts +0 -19
- package/dist/hooks/useNotifications.d.ts.map +0 -1
- package/dist/hooks/useTheme.d.ts.map +0 -1
- package/dist/hooks/useToast.d.ts +0 -17
- package/dist/hooks/useToast.d.ts.map +0 -1
- package/dist/hooks/useUser.d.ts.map +0 -1
- package/dist/index.cjs +0 -630
- package/dist/index.d.ts +0 -17
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -600
- package/dist/layouts/DefaultLayout.d.ts +0 -9
- package/dist/layouts/DefaultLayout.d.ts.map +0 -1
- package/dist/types/ai-coach.d.ts +0 -22
- package/dist/types/ai-coach.d.ts.map +0 -1
- package/dist/types/bookmarks.d.ts +0 -19
- package/dist/types/bookmarks.d.ts.map +0 -1
- package/dist/types/bundle.d.ts +0 -114
- package/dist/types/bundle.d.ts.map +0 -1
- package/dist/types/common.d.ts +0 -23
- package/dist/types/common.d.ts.map +0 -1
- package/dist/types/course.d.ts +0 -127
- package/dist/types/course.d.ts.map +0 -1
- package/dist/types/enrollment.d.ts +0 -34
- package/dist/types/enrollment.d.ts.map +0 -1
- package/dist/types/index.cjs +0 -18
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -0
- package/dist/types/lesson.d.ts +0 -105
- package/dist/types/lesson.d.ts.map +0 -1
- package/dist/types/manual-review.d.ts +0 -123
- package/dist/types/manual-review.d.ts.map +0 -1
- package/dist/types/messaging.d.ts +0 -101
- package/dist/types/messaging.d.ts.map +0 -1
- package/dist/types/notification.d.ts +0 -28
- package/dist/types/notification.d.ts.map +0 -1
- package/dist/types/payment.d.ts +0 -38
- package/dist/types/payment.d.ts.map +0 -1
- package/dist/types/progress.d.ts +0 -18
- package/dist/types/progress.d.ts.map +0 -1
- package/dist/types/rating.d.ts +0 -20
- package/dist/types/rating.d.ts.map +0 -1
- package/dist/types/search.d.ts +0 -28
- package/dist/types/search.d.ts.map +0 -1
- package/dist/types/user.d.ts +0 -15
- package/dist/types/user.d.ts.map +0 -1
- package/dist/utils/formatters.d.ts +0 -25
- package/dist/utils/formatters.d.ts.map +0 -1
- package/dist/utils/index.cjs +0 -80
- package/dist/utils/index.d.ts +0 -2
- package/dist/utils/index.d.ts.map +0 -1
- 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 "{(appliedCoupon as any).code}" 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
|
+
}
|