refactor(education): CourseDetailView module with hook, subcomponents, skeleton

- Add course-detail-view/ with useCourseDetailView, Header, Tabs, Sidebar, Skeleton
- Stories: Default, Loading (Skeleton), Empty, Enrolled
- Re-export from CourseDetailView.tsx

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-05 23:52:24 +01:00
parent cbd730f6b5
commit 423b9adb8e
10 changed files with 574 additions and 349 deletions

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CourseDetailView } from './CourseDetailView';
import { CourseDetailView, CourseDetailViewSkeleton } from './CourseDetailView';
const meta: Meta<typeof CourseDetailView> = {
title: 'Components/Features/Education/CourseDetailView',
@ -71,6 +71,25 @@ export const Default: Story = {
}
};
export const Loading: Story = {
name: 'Chargement',
render: () => <CourseDetailViewSkeleton />,
};
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: {

View file

@ -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<CourseDetailViewProps> = ({
course,
onBack,
onEnroll,
isEnrolled,
}) => {
const { addToast: _addToast } = useToast();
const [activeTab, setActiveTab] = useState<
'overview' | 'curriculum' | 'reviews'
>('overview');
const [expandedModule, setExpandedModule] = useState<string | null>(
course.modules?.[0].id || null,
);
const toggleModule = (id: string) => {
setExpandedModule(expandedModule === id ? null : id);
};
return (
<div className="animate-fadeIn pb-20 max-w-7xl mx-auto">
{/* Breadcrumb */}
<div className="mb-6">
<Button
variant="ghost"
onClick={onBack}
className="pl-0 text-kodo-content-dim hover:text-white"
>
Back to Courses
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Content */}
<div className="lg:col-span-2 space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl md:text-4xl font-display font-bold text-white mb-4">
{course.title}
</h1>
<p className="text-xl text-kodo-text-main mb-6 font-light">
{course.description}
</p>
<div className="flex flex-wrap items-center gap-6 text-sm text-kodo-content-dim mb-6">
{course.rating && (
<span className="flex items-center gap-1 text-kodo-gold font-bold">
<Star className="w-4 h-4 fill-current" /> {course.rating}
</span>
)}
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />{' '}
{(course.studentCount || 0).toLocaleString()} students
</span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" /> {course.duration} total
</span>
<span className="flex items-center gap-1">
<Globe className="w-4 h-4" /> English
</span>
</div>
<div className="flex items-center gap-4">
<img
src={`https://ui-avatars.com/api/?name=${course.instructor}&background=random`}
className="w-10 h-10 rounded-full"
/>
<div>
<div className="text-xs text-kodo-content-dim uppercase">
Created by
</div>
<div className="text-sm font-bold text-white text-kodo-cyan cursor-pointer hover:underline">
{course.instructor}
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-kodo-steel flex gap-6">
{['overview', 'curriculum', 'reviews'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === tab ? 'border-kodo-cyan text-white' : 'border-transparent text-kodo-content-dim hover:text-kodo-text-main'}`}
>
{tab}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="space-y-8 animate-fadeIn">
<Card variant="default">
<h3 className="font-bold text-white text-lg mb-4">
What you'll learn
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{course.whatYouWillLearn?.map((item, i) => (
<div key={i} className="flex gap-4 text-sm text-kodo-text-main">
<CheckCircle className="w-4 h-4 text-kodo-lime flex-shrink-0 mt-0.5" />
<span>{item}</span>
</div>
))}
</div>
</Card>
<div>
<h3 className="font-bold text-white text-lg mb-4">
Requirements
</h3>
<ul className="list-disc pl-5 space-y-1 text-sm text-kodo-content-dim">
{course.requirements?.map((req, i) => (
<li key={i}>{req}</li>
))}
</ul>
</div>
</div>
)}
{activeTab === 'curriculum' && (
<div className="space-y-4 animate-fadeIn">
<div className="flex justify-between items-center text-sm text-kodo-content-dim mb-2">
<span>
{course.modules?.length} Modules {' '}
{course.modules?.reduce(
(acc, m) => acc + m.lessons.length,
0,
)}{' '}
Lessons
</span>
<button
className="text-kodo-cyan hover:underline"
onClick={() =>
setExpandedModule(expandedModule ? null : 'all')
}
>
{expandedModule === 'all' ? 'Collapse All' : 'Expand All'}
</button>
</div>
{course.modules?.map((module) => (
<div
key={module.id}
className="border border-kodo-steel rounded-lg overflow-hidden bg-kodo-ink/30"
>
<div
className="p-4 flex justify-between items-center cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => toggleModule(module.id)}
>
<h4 className="font-bold text-white flex items-center gap-4">
{expandedModule === module.id ||
expandedModule === 'all' ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
{module.title}
</h4>
<span className="text-xs text-kodo-content-dim">
{module.lessons.length} lectures
</span>
</div>
{(expandedModule === module.id ||
expandedModule === 'all') && (
<div className="border-t border-kodo-steel">
{module.lessons.map((lesson) => (
<div
key={lesson.id}
className="p-4 pl-8 flex justify-between items-center hover:bg-white/5 border-b border-kodo-steel/30 last:border-0"
>
<div className="flex items-center gap-4 text-sm text-kodo-text-main">
{lesson.type === 'video' ? (
<PlayCircle className="w-4 h-4" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
{lesson.title}
</div>
<div className="flex items-center gap-4">
{lesson.isLocked && !isEnrolled && (
<Lock className="w-3 h-3 text-kodo-content-dim" />
)}
<span className="text-xs text-kodo-content-dim">
{lesson.duration}
</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{activeTab === 'reviews' && (
<div className="space-y-6 animate-fadeIn">
{course.reviews?.map((review) => (
<div
key={review.id}
className="border-b border-kodo-steel/50 pb-6"
>
<div className="flex items-center gap-4 mb-2">
<img
src={review.avatar}
className="w-10 h-10 rounded-full"
/>
<div>
<div className="font-bold text-white text-sm">
{review.username}
</div>
<div className="flex text-kodo-gold text-xs">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-3 h-3 ${i < review.rating ? 'fill-current' : 'text-kodo-text-main'}`}
/>
))}
</div>
</div>
<span className="ml-auto text-xs text-kodo-content-dim">
{review.date}
</span>
</div>
<p className="text-sm text-kodo-text-main">{review.comment}</p>
</div>
))}
</div>
)}
</div>
{/* Right Sidebar */}
<div className="relative">
<div className="sticky top-24 space-y-6">
<Card
variant="default"
className="p-0 overflow-hidden border-kodo-steel/30"
>
{/* Preview Video Placeholder */}
<div className="relative aspect-video bg-black group cursor-pointer">
<img
src={course.thumbnailUrl}
className="w-full h-full object-cover opacity-80"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
<PlayCircle className="w-8 h-8 text-black fill-current" />
</div>
</div>
<div className="absolute bottom-4 text-center w-full text-white font-bold text-sm drop-shadow-md">
Preview Course
</div>
</div>
<div className="p-6">
<div className="text-3xl font-display font-bold text-white mb-2">
{isEnrolled
? 'Enrolled'
: course.price && course.price > 0
? `$${course.price}`
: 'Free'}
</div>
{course.price && course.price > 0 && !isEnrolled && (
<p className="text-kodo-content-dim text-xs mb-6 line-through">
$199.99 (85% off)
</p>
)}
{isEnrolled ? (
<Button
variant="primary"
className="w-full h-12 text-lg"
onClick={onEnroll}
>
CONTINUE LEARNING
</Button>
) : (
<div className="space-y-4">
<Button
variant="primary"
className="w-full h-12 text-lg"
onClick={onEnroll}
>
ENROLL NOW
</Button>
<p className="text-center text-xs text-kodo-content-dim">
30-Day Money-Back Guarantee
</p>
</div>
)}
<div className="mt-6 space-y-4">
<h4 className="font-bold text-white text-sm">
This course includes:
</h4>
<ul className="text-sm text-kodo-content-dim space-y-2">
<li className="flex items-center gap-4">
<PlayCircle className="w-4 h-4" /> {course.duration}{' '}
on-demand video
</li>
<li className="flex items-center gap-4">
<ShieldCheck className="w-4 h-4" /> Full lifetime access
</li>
<li className="flex items-center gap-4">
<Globe className="w-4 h-4" /> Access on mobile and TV
</li>
{course.certificateAvailable && (
<li className="flex items-center gap-4">
<Star className="w-4 h-4" /> Certificate of completion
</li>
)}
</ul>
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
);
};
/**
* CourseDetailView re-export from feature module.
*/
export {
CourseDetailView,
CourseDetailViewSkeleton,
} from './course-detail-view';
export type { CourseDetailViewProps } from './course-detail-view';

View file

@ -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<CourseDetailViewProps> = ({
course,
onBack,
onEnroll,
isEnrolled = false,
isLoading = false,
}) => {
const {
activeTab,
setActiveTab,
expandedModule,
toggleModule,
expandCollapseAll,
} = useCourseDetailView(course);
if (isLoading) {
return <CourseDetailViewSkeleton />;
}
return (
<div className="animate-fadeIn pb-20 max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<CourseDetailViewHeader course={course} onBack={onBack} />
<CourseDetailViewTabs
course={course}
activeTab={activeTab}
onTabChange={setActiveTab}
expandedModule={expandedModule}
onToggleModule={toggleModule}
onExpandCollapseAll={expandCollapseAll}
isEnrolled={isEnrolled}
/>
</div>
<CourseDetailViewSidebar
course={course}
isEnrolled={isEnrolled}
onEnroll={onEnroll}
/>
</div>
</div>
);
};

View file

@ -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 (
<div>
<div className="mb-6">
<Button
variant="ghost"
onClick={onBack}
className="pl-0 text-kodo-content-dim hover:text-white"
>
Back to Courses
</Button>
</div>
<h1 className="text-3xl md:text-4xl font-display font-bold text-white mb-4">
{course.title}
</h1>
<p className="text-xl text-kodo-text-main mb-6 font-light">
{course.description}
</p>
<div className="flex flex-wrap items-center gap-6 text-sm text-kodo-content-dim mb-6">
{course.rating != null && (
<span className="flex items-center gap-1 text-kodo-gold font-bold">
<Star className="w-4 h-4 fill-current" /> {course.rating}
</span>
)}
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />{' '}
{(course.studentCount ?? 0).toLocaleString()} students
</span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" /> {course.duration} total
</span>
<span className="flex items-center gap-1">
<Globe className="w-4 h-4" /> English
</span>
</div>
<div className="flex items-center gap-4">
<img
src={`https://ui-avatars.com/api/?name=${encodeURIComponent(course.instructor)}&background=random`}
alt=""
className="w-10 h-10 rounded-full"
/>
<div>
<div className="text-xs text-kodo-content-dim uppercase">
Created by
</div>
<div className="text-sm font-bold text-white text-kodo-cyan cursor-pointer hover:underline">
{course.instructor}
</div>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="relative">
<div className="sticky top-24 space-y-6">
<Card
variant="default"
className="p-0 overflow-hidden border-kodo-steel/30"
>
<div className="relative aspect-video bg-black group cursor-pointer">
<img
src={course.thumbnailUrl}
alt=""
className="w-full h-full object-cover opacity-80"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
<PlayCircle className="w-8 h-8 text-black fill-current" />
</div>
</div>
<div className="absolute bottom-4 text-center w-full text-white font-bold text-sm drop-shadow-md">
Preview Course
</div>
</div>
<div className="p-6">
<div className="text-3xl font-display font-bold text-white mb-2">
{priceLabel}
</div>
{course.price != null && course.price > 0 && !isEnrolled && (
<p className="text-kodo-content-dim text-xs mb-6 line-through">
$199.99 (85% off)
</p>
)}
{isEnrolled ? (
<Button
variant="primary"
className="w-full h-12 text-lg"
onClick={onEnroll}
>
CONTINUE LEARNING
</Button>
) : (
<div className="space-y-4">
<Button
variant="primary"
className="w-full h-12 text-lg"
onClick={onEnroll}
>
ENROLL NOW
</Button>
<p className="text-center text-xs text-kodo-content-dim">
30-Day Money-Back Guarantee
</p>
</div>
)}
<div className="mt-6 space-y-4">
<h4 className="font-bold text-white text-sm">
This course includes:
</h4>
<ul className="text-sm text-kodo-content-dim space-y-2">
<li className="flex items-center gap-4">
<PlayCircle className="w-4 h-4" /> {course.duration} on-demand
video
</li>
<li className="flex items-center gap-4">
<ShieldCheck className="w-4 h-4" /> Full lifetime access
</li>
<li className="flex items-center gap-4">
<Globe className="w-4 h-4" /> Access on mobile and TV
</li>
{course.certificateAvailable && (
<li className="flex items-center gap-4">
<Star className="w-4 h-4" /> Certificate of completion
</li>
)}
</ul>
</div>
</div>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
/**
* CourseDetailView loading skeleton (layout primitive).
*/
import { cn } from '@/lib/utils';
interface CourseDetailViewSkeletonProps {
className?: string;
}
export function CourseDetailViewSkeleton({
className,
}: CourseDetailViewSkeletonProps) {
return (
<div
className={cn(
'animate-fadeIn pb-20 max-w-7xl mx-auto min-h-layout-page-sm',
className,
)}
>
<div className="mb-6 h-9 w-32 rounded bg-muted animate-pulse" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<div>
<div className="h-9 w-3/4 rounded bg-muted animate-pulse mb-4" />
<div className="h-6 w-full max-w-2xl rounded bg-muted/70 animate-pulse mb-6" />
<div className="flex flex-wrap gap-6 mb-6">
<div className="h-4 w-16 rounded bg-muted animate-pulse" />
<div className="h-4 w-24 rounded bg-muted animate-pulse" />
<div className="h-4 w-20 rounded bg-muted animate-pulse" />
</div>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted animate-pulse" />
<div className="h-4 w-24 rounded bg-muted animate-pulse" />
</div>
</div>
<div className="flex gap-6 border-b border-border pb-3">
<div className="h-4 w-20 rounded bg-muted animate-pulse" />
<div className="h-4 w-20 rounded bg-muted animate-pulse" />
<div className="h-4 w-16 rounded bg-muted animate-pulse" />
</div>
<div className="space-y-4">
<div className="h-24 w-full rounded bg-muted/50 animate-pulse" />
<div className="h-4 w-48 rounded bg-muted animate-pulse" />
</div>
</div>
<div className="relative">
<div className="sticky top-24 space-y-6">
<div className="rounded-lg overflow-hidden border border-border">
<div className="aspect-video bg-muted animate-pulse" />
<div className="p-6 space-y-4">
<div className="h-8 w-20 rounded bg-muted animate-pulse" />
<div className="h-12 w-full rounded bg-muted animate-pulse" />
<div className="h-4 w-full rounded bg-muted/50 animate-pulse" />
<div className="h-4 w-full rounded bg-muted/50 animate-pulse" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -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 (
<>
<div className="border-b border-kodo-steel flex gap-6">
{TABS.map((tab) => (
<button
key={tab}
type="button"
onClick={() => onTabChange(tab)}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === tab ? 'border-kodo-cyan text-white' : 'border-transparent text-kodo-content-dim hover:text-kodo-text-main'}`}
>
{tab}
</button>
))}
</div>
{activeTab === 'overview' && (
<div className="space-y-8 animate-fadeIn">
<Card variant="default">
<h3 className="font-bold text-white text-lg mb-4">
What you&apos;ll learn
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(course.whatYouWillLearn ?? []).map((item, i) => (
<div
key={i}
className="flex gap-4 text-sm text-kodo-text-main"
>
<CheckCircle className="w-4 h-4 text-kodo-lime flex-shrink-0 mt-0.5" />
<span>{item}</span>
</div>
))}
</div>
</Card>
<div>
<h3 className="font-bold text-white text-lg mb-4">
Requirements
</h3>
<ul className="list-disc pl-5 space-y-1 text-sm text-kodo-content-dim">
{(course.requirements ?? []).map((req, i) => (
<li key={i}>{req}</li>
))}
</ul>
</div>
</div>
)}
{activeTab === 'curriculum' && (
<div className="space-y-4 animate-fadeIn">
<div className="flex justify-between items-center text-sm text-kodo-content-dim mb-2">
<span>
{course.modules?.length ?? 0} Modules {totalLessons} Lessons
</span>
<button
type="button"
className="text-kodo-cyan hover:underline"
onClick={onExpandCollapseAll}
>
{expandedModule === 'all' ? 'Collapse All' : 'Expand All'}
</button>
</div>
{(course.modules ?? []).map((module) => (
<div
key={module.id}
className="border border-kodo-steel rounded-lg overflow-hidden bg-kodo-ink/30"
>
<button
type="button"
className="p-4 flex justify-between items-center cursor-pointer hover:bg-white/5 transition-colors w-full text-left"
onClick={() => onToggleModule(module.id)}
>
<h4 className="font-bold text-white flex items-center gap-4">
{expandedModule === module.id || expandedModule === 'all' ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
{module.title}
</h4>
<span className="text-xs text-kodo-content-dim">
{module.lessons.length} lectures
</span>
</button>
{(expandedModule === module.id || expandedModule === 'all') && (
<div className="border-t border-kodo-steel">
{module.lessons.map((lesson) => (
<div
key={lesson.id}
className="p-4 pl-8 flex justify-between items-center hover:bg-white/5 border-b border-kodo-steel/30 last:border-0"
>
<div className="flex items-center gap-4 text-sm text-kodo-text-main">
{lesson.type === 'video' ? (
<PlayCircle className="w-4 h-4" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
{lesson.title}
</div>
<div className="flex items-center gap-4">
{lesson.isLocked && !isEnrolled && (
<Lock className="w-3 h-3 text-kodo-content-dim" />
)}
<span className="text-xs text-kodo-content-dim">
{lesson.duration}
</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{activeTab === 'reviews' && (
<div className="space-y-6 animate-fadeIn">
{(course.reviews ?? []).map((review) => (
<div
key={review.id}
className="border-b border-kodo-steel/50 pb-6"
>
<div className="flex items-center gap-4 mb-2">
<img
src={review.avatar}
alt=""
className="w-10 h-10 rounded-full"
/>
<div>
<div className="font-bold text-white text-sm">
{review.username}
</div>
<div className="flex text-kodo-gold text-xs">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-3 h-3 ${i < review.rating ? 'fill-current' : 'text-kodo-text-main'}`}
/>
))}
</div>
</div>
<span className="ml-auto text-xs text-kodo-content-dim">
{review.date}
</span>
</div>
<p className="text-sm text-kodo-text-main">{review.comment}</p>
</div>
))}
</div>
)}
</>
);
}

View file

@ -0,0 +1,6 @@
/**
* CourseDetailView public API.
*/
export { CourseDetailView } from './CourseDetailView';
export { CourseDetailViewSkeleton } from './CourseDetailViewSkeleton';
export type { CourseDetailViewProps, Course } from './types';

View file

@ -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 };

View file

@ -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<CourseDetailTab>('overview');
const firstModuleId = course.modules?.[0]?.id ?? null;
const [expandedModule, setExpandedModule] = useState<string | null>(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,
};
}