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:
parent
cbd730f6b5
commit
423b9adb8e
10 changed files with 574 additions and 349 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* CourseDetailView — public API.
|
||||
*/
|
||||
export { CourseDetailView } from './CourseDetailView';
|
||||
export { CourseDetailViewSkeleton } from './CourseDetailViewSkeleton';
|
||||
export type { CourseDetailViewProps, Course } from './types';
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue