diff --git a/apps/web/src/components/education/CourseDetailView.stories.tsx b/apps/web/src/components/education/CourseDetailView.stories.tsx index f4ed63903..dc0d8494d 100644 --- a/apps/web/src/components/education/CourseDetailView.stories.tsx +++ b/apps/web/src/components/education/CourseDetailView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { CourseDetailView } from './CourseDetailView'; +import { CourseDetailView, CourseDetailViewSkeleton } from './CourseDetailView'; const meta: Meta = { title: 'Components/Features/Education/CourseDetailView', @@ -71,6 +71,25 @@ export const Default: Story = { } }; +export const Loading: Story = { + name: 'Chargement', + render: () => , +}; + +export const Empty: Story = { + name: 'Vide (sans modules ni avis)', + args: { + course: { + ...mockCourse, + modules: [], + reviews: [] + }, + onBack: () => console.log('Back clicked'), + onEnroll: () => console.log('Enroll clicked'), + isEnrolled: false + } +}; + export const Enrolled: Story = { name: 'Inscrit', args: { diff --git a/apps/web/src/components/education/CourseDetailView.tsx b/apps/web/src/components/education/CourseDetailView.tsx index 18c487147..d6ab74a33 100644 --- a/apps/web/src/components/education/CourseDetailView.tsx +++ b/apps/web/src/components/education/CourseDetailView.tsx @@ -1,348 +1,8 @@ -import React, { useState } from 'react'; -import { Button } from '../ui/button'; -import { Card } from '../ui/card'; -import { Course } from '../../types'; -import { - PlayCircle, - Star, - Users, - CheckCircle, - Clock, - Globe, - ShieldCheck, - Lock, - ChevronDown, - ChevronUp, -} from 'lucide-react'; -import { useToast } from '../../components/feedback/ToastProvider'; - -interface CourseDetailViewProps { - course: Course; - onBack: () => void; - onEnroll: () => void; - isEnrolled?: boolean; -} - -export const CourseDetailView: React.FC = ({ - course, - onBack, - onEnroll, - isEnrolled, -}) => { - const { addToast: _addToast } = useToast(); - const [activeTab, setActiveTab] = useState< - 'overview' | 'curriculum' | 'reviews' - >('overview'); - const [expandedModule, setExpandedModule] = useState( - course.modules?.[0].id || null, - ); - - const toggleModule = (id: string) => { - setExpandedModule(expandedModule === id ? null : id); - }; - - return ( -
- {/* Breadcrumb */} -
- -
- -
- {/* Left Content */} -
- {/* Header */} -
-

- {course.title} -

-

- {course.description} -

- -
- {course.rating && ( - - {course.rating} - - )} - - {' '} - {(course.studentCount || 0).toLocaleString()} students - - - {course.duration} total - - - English - -
- -
- -
-
- Created by -
-
- {course.instructor} -
-
-
-
- - {/* Tabs */} -
- {['overview', 'curriculum', 'reviews'].map((tab) => ( - - ))} -
- - {/* Tab Content */} - {activeTab === 'overview' && ( -
- -

- What you'll learn -

-
- {course.whatYouWillLearn?.map((item, i) => ( -
- - {item} -
- ))} -
-
- -
-

- Requirements -

-
    - {course.requirements?.map((req, i) => ( -
  • {req}
  • - ))} -
-
-
- )} - - {activeTab === 'curriculum' && ( -
-
- - {course.modules?.length} Modules •{' '} - {course.modules?.reduce( - (acc, m) => acc + m.lessons.length, - 0, - )}{' '} - Lessons - - -
- - {course.modules?.map((module) => ( -
-
toggleModule(module.id)} - > -

- {expandedModule === module.id || - expandedModule === 'all' ? ( - - ) : ( - - )} - {module.title} -

- - {module.lessons.length} lectures - -
- - {(expandedModule === module.id || - expandedModule === 'all') && ( -
- {module.lessons.map((lesson) => ( -
-
- {lesson.type === 'video' ? ( - - ) : ( - - )} - {lesson.title} -
-
- {lesson.isLocked && !isEnrolled && ( - - )} - - {lesson.duration} - -
-
- ))} -
- )} -
- ))} -
- )} - - {activeTab === 'reviews' && ( -
- {course.reviews?.map((review) => ( -
-
- -
-
- {review.username} -
-
- {[...Array(5)].map((_, i) => ( - - ))} -
-
- - {review.date} - -
-

{review.comment}

-
- ))} -
- )} -
- - {/* Right Sidebar */} -
-
- - {/* Preview Video Placeholder */} -
- -
-
- -
-
-
- Preview Course -
-
- -
-
- {isEnrolled - ? 'Enrolled' - : course.price && course.price > 0 - ? `$${course.price}` - : 'Free'} -
- {course.price && course.price > 0 && !isEnrolled && ( -

- $199.99 (85% off) -

- )} - - {isEnrolled ? ( - - ) : ( -
- -

- 30-Day Money-Back Guarantee -

-
- )} - -
-

- This course includes: -

-
    -
  • - {course.duration}{' '} - on-demand video -
  • -
  • - Full lifetime access -
  • -
  • - Access on mobile and TV -
  • - {course.certificateAvailable && ( -
  • - Certificate of completion -
  • - )} -
-
-
-
-
-
-
-
- ); -}; +/** + * CourseDetailView — re-export from feature module. + */ +export { + CourseDetailView, + CourseDetailViewSkeleton, +} from './course-detail-view'; +export type { CourseDetailViewProps } from './course-detail-view'; diff --git a/apps/web/src/components/education/course-detail-view/CourseDetailView.tsx b/apps/web/src/components/education/course-detail-view/CourseDetailView.tsx new file mode 100644 index 000000000..e85e84de1 --- /dev/null +++ b/apps/web/src/components/education/course-detail-view/CourseDetailView.tsx @@ -0,0 +1,54 @@ +/** + * CourseDetailView — orchestration: hook + Header, Tabs, Sidebar. + */ +import React from 'react'; +import { useCourseDetailView } from './useCourseDetailView'; +import { CourseDetailViewHeader } from './CourseDetailViewHeader'; +import { CourseDetailViewTabs } from './CourseDetailViewTabs'; +import { CourseDetailViewSidebar } from './CourseDetailViewSidebar'; +import { CourseDetailViewSkeleton } from './CourseDetailViewSkeleton'; +import type { CourseDetailViewProps } from './types'; + +export const CourseDetailView: React.FC = ({ + course, + onBack, + onEnroll, + isEnrolled = false, + isLoading = false, +}) => { + const { + activeTab, + setActiveTab, + expandedModule, + toggleModule, + expandCollapseAll, + } = useCourseDetailView(course); + + if (isLoading) { + return ; + } + + return ( +
+
+
+ + +
+ +
+
+ ); +}; diff --git a/apps/web/src/components/education/course-detail-view/CourseDetailViewHeader.tsx b/apps/web/src/components/education/course-detail-view/CourseDetailViewHeader.tsx new file mode 100644 index 000000000..a9a0772bc --- /dev/null +++ b/apps/web/src/components/education/course-detail-view/CourseDetailViewHeader.tsx @@ -0,0 +1,72 @@ +/** + * CourseDetailView — breadcrumb, title, description, meta, instructor. + */ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Star, Users, Clock, Globe } from 'lucide-react'; +import type { Course } from '@/types'; + +interface CourseDetailViewHeaderProps { + course: Course; + onBack: () => void; +} + +export function CourseDetailViewHeader({ + course, + onBack, +}: CourseDetailViewHeaderProps) { + return ( +
+
+ +
+ +

+ {course.title} +

+

+ {course.description} +

+ +
+ {course.rating != null && ( + + {course.rating} + + )} + + {' '} + {(course.studentCount ?? 0).toLocaleString()} students + + + {course.duration} total + + + English + +
+ +
+ +
+
+ Created by +
+
+ {course.instructor} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/education/course-detail-view/CourseDetailViewSidebar.tsx b/apps/web/src/components/education/course-detail-view/CourseDetailViewSidebar.tsx new file mode 100644 index 000000000..678b6db9c --- /dev/null +++ b/apps/web/src/components/education/course-detail-view/CourseDetailViewSidebar.tsx @@ -0,0 +1,111 @@ +/** + * CourseDetailView — sticky sidebar: preview, price, enroll, includes. + */ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { PlayCircle, ShieldCheck, Globe, Star } from 'lucide-react'; +import type { Course } from '@/types'; + +interface CourseDetailViewSidebarProps { + course: Course; + isEnrolled?: boolean; + onEnroll: () => void; +} + +export function CourseDetailViewSidebar({ + course, + isEnrolled = false, + onEnroll, +}: CourseDetailViewSidebarProps) { + const priceLabel = + isEnrolled + ? 'Enrolled' + : (course.price != null && course.price > 0) + ? `$${course.price}` + : 'Free'; + + return ( +
+
+ +
+ +
+
+ +
+
+
+ Preview Course +
+
+ +
+
+ {priceLabel} +
+ {course.price != null && course.price > 0 && !isEnrolled && ( +

+ $199.99 (85% off) +

+ )} + + {isEnrolled ? ( + + ) : ( +
+ +

+ 30-Day Money-Back Guarantee +

+
+ )} + +
+

+ This course includes: +

+
    +
  • + {course.duration} on-demand + video +
  • +
  • + Full lifetime access +
  • +
  • + Access on mobile and TV +
  • + {course.certificateAvailable && ( +
  • + Certificate of completion +
  • + )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/education/course-detail-view/CourseDetailViewSkeleton.tsx b/apps/web/src/components/education/course-detail-view/CourseDetailViewSkeleton.tsx new file mode 100644 index 000000000..0a6971a94 --- /dev/null +++ b/apps/web/src/components/education/course-detail-view/CourseDetailViewSkeleton.tsx @@ -0,0 +1,62 @@ +/** + * CourseDetailView — loading skeleton (layout primitive). + */ +import { cn } from '@/lib/utils'; + +interface CourseDetailViewSkeletonProps { + className?: string; +} + +export function CourseDetailViewSkeleton({ + className, +}: CourseDetailViewSkeletonProps) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/education/course-detail-view/CourseDetailViewTabs.tsx b/apps/web/src/components/education/course-detail-view/CourseDetailViewTabs.tsx new file mode 100644 index 000000000..959d58e9b --- /dev/null +++ b/apps/web/src/components/education/course-detail-view/CourseDetailViewTabs.tsx @@ -0,0 +1,196 @@ +/** + * CourseDetailView — tab bar and content: overview, curriculum, reviews. + */ +import React from 'react'; +import { Card } from '@/components/ui/card'; +import { + CheckCircle, + ChevronDown, + ChevronUp, + PlayCircle, + ShieldCheck, + Lock, + Star, +} from 'lucide-react'; +import type { Course } from '@/types'; +import type { CourseDetailTab } from './types'; + +interface CourseDetailViewTabsProps { + course: Course; + activeTab: CourseDetailTab; + onTabChange: (tab: CourseDetailTab) => void; + expandedModule: string | null; + onToggleModule: (id: string) => void; + onExpandCollapseAll: () => void; + isEnrolled?: boolean; +} + +const TABS: CourseDetailTab[] = ['overview', 'curriculum', 'reviews']; + +export function CourseDetailViewTabs({ + course, + activeTab, + onTabChange, + expandedModule, + onToggleModule, + onExpandCollapseAll, + isEnrolled = false, +}: CourseDetailViewTabsProps) { + const totalLessons = + course.modules?.reduce((acc, m) => acc + m.lessons.length, 0) ?? 0; + + return ( + <> +
+ {TABS.map((tab) => ( + + ))} +
+ + {activeTab === 'overview' && ( +
+ +

+ What you'll learn +

+
+ {(course.whatYouWillLearn ?? []).map((item, i) => ( +
+ + {item} +
+ ))} +
+
+ +
+

+ Requirements +

+
    + {(course.requirements ?? []).map((req, i) => ( +
  • {req}
  • + ))} +
+
+
+ )} + + {activeTab === 'curriculum' && ( +
+
+ + {course.modules?.length ?? 0} Modules • {totalLessons} Lessons + + +
+ + {(course.modules ?? []).map((module) => ( +
+ + + {(expandedModule === module.id || expandedModule === 'all') && ( +
+ {module.lessons.map((lesson) => ( +
+
+ {lesson.type === 'video' ? ( + + ) : ( + + )} + {lesson.title} +
+
+ {lesson.isLocked && !isEnrolled && ( + + )} + + {lesson.duration} + +
+
+ ))} +
+ )} +
+ ))} +
+ )} + + {activeTab === 'reviews' && ( +
+ {(course.reviews ?? []).map((review) => ( +
+
+ +
+
+ {review.username} +
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+ + {review.date} + +
+

{review.comment}

+
+ ))} +
+ )} + + ); +} diff --git a/apps/web/src/components/education/course-detail-view/index.ts b/apps/web/src/components/education/course-detail-view/index.ts new file mode 100644 index 000000000..e67945dd4 --- /dev/null +++ b/apps/web/src/components/education/course-detail-view/index.ts @@ -0,0 +1,6 @@ +/** + * CourseDetailView — public API. + */ +export { CourseDetailView } from './CourseDetailView'; +export { CourseDetailViewSkeleton } from './CourseDetailViewSkeleton'; +export type { CourseDetailViewProps, Course } from './types'; diff --git a/apps/web/src/components/education/course-detail-view/types.ts b/apps/web/src/components/education/course-detail-view/types.ts new file mode 100644 index 000000000..aedc572c4 --- /dev/null +++ b/apps/web/src/components/education/course-detail-view/types.ts @@ -0,0 +1,16 @@ +/** + * CourseDetailView — public props and tab type. + */ +import type { Course } from '@/types'; + +export type CourseDetailTab = 'overview' | 'curriculum' | 'reviews'; + +export interface CourseDetailViewProps { + course: Course; + onBack: () => void; + onEnroll: () => void; + isEnrolled?: boolean; + isLoading?: boolean; +} + +export type { Course }; diff --git a/apps/web/src/components/education/course-detail-view/useCourseDetailView.ts b/apps/web/src/components/education/course-detail-view/useCourseDetailView.ts new file mode 100644 index 000000000..cf81bd277 --- /dev/null +++ b/apps/web/src/components/education/course-detail-view/useCourseDetailView.ts @@ -0,0 +1,29 @@ +/** + * CourseDetailView — tab and accordion state. + */ +import { useState, useCallback } from 'react'; +import type { Course } from '@/types'; +import type { CourseDetailTab } from './types'; + +export function useCourseDetailView(course: Course) { + const [activeTab, setActiveTab] = useState('overview'); + const firstModuleId = course.modules?.[0]?.id ?? null; + const [expandedModule, setExpandedModule] = useState(firstModuleId); + + const toggleModule = useCallback((id: string) => { + setExpandedModule((prev) => (prev === id ? null : id)); + }, []); + + const expandCollapseAll = useCallback(() => { + setExpandedModule((prev) => (prev === 'all' ? null : 'all')); + }, []); + + return { + activeTab, + setActiveTab, + expandedModule, + setExpandedModule, + toggleModule, + expandCollapseAll, + }; +}