Files
meet-hub/src/pages/Bootcamp.tsx
dwindown 95fd4d3859 Add video chapter/timeline navigation feature
Implement timeline chapters for webinar and bootcamp videos with click-to-jump functionality:

**Components:**
- VideoPlayerWithChapters: Plyr.io-based player with chapter support
- TimelineChapters: Clickable chapter markers with active state
- ChaptersEditor: Admin UI for managing video chapters

**Features:**
- YouTube videos: Clickable timestamps that jump to specific time
- Embed videos: Static timeline display (non-clickable)
- Real-time chapter tracking during playback
- Admin-defined accent color for Plyr theme
- Auto-hides timeline when no chapters configured

**Database:**
- Add chapters JSONB column to products table (webinars)
- Add chapters JSONB column to bootcamp_lessons table
- Create indexes for faster queries

**Updated Pages:**
- WebinarRecording: Two-column layout (video + timeline)
- Bootcamp: Per-lesson chapter support
- AdminProducts: Chapter editor for webinars
- CurriculumEditor: Chapter editor for lessons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 23:31:23 +07:00

728 lines
27 KiB
TypeScript

import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast';
import { formatDuration } from '@/lib/format';
import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { ReviewModal } from '@/components/reviews/ReviewModal';
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
import { TimelineChapters } from '@/components/TimelineChapters';
import DOMPurify from 'dompurify';
interface VideoChapter {
time: number;
title: string;
}
interface Product {
id: string;
title: string;
slug: string;
video_source?: string;
}
interface Module {
id: string;
title: string;
position: number;
lessons: Lesson[];
}
interface Lesson {
id: string;
title: string;
content: string | null;
video_url: string | null;
youtube_url: string | null;
embed_code: string | null;
duration_seconds: number | null;
position: number;
release_at: string | null;
chapters?: VideoChapter[];
}
interface Progress {
lesson_id: string;
completed_at: string;
}
interface UserReview {
id: string;
rating: number;
title: string;
body: string;
is_approved: boolean;
created_at: string;
}
export default function Bootcamp() {
const { slug, lessonId } = useParams<{ slug: string; lessonId?: string }>();
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [product, setProduct] = useState<Product | null>(null);
const [modules, setModules] = useState<Module[]>([]);
const [progress, setProgress] = useState<Progress[]>([]);
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null);
const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [userReview, setUserReview] = useState<UserReview | null>(null);
const [reviewModalOpen, setReviewModalOpen] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [accentColor, setAccentColor] = useState('#f97316');
const playerRef = useRef<VideoPlayerRef>(null);
useEffect(() => {
if (!authLoading && !user) {
navigate('/auth');
} else if (user && slug) {
checkAccessAndFetch();
}
}, [user, authLoading, slug]);
const checkAccessAndFetch = async () => {
const { data: productData, error: productError } = await supabase
.from('products')
.select('id, title, slug, video_source')
.eq('slug', slug)
.eq('type', 'bootcamp')
.maybeSingle();
if (productError || !productData) {
toast({ title: 'Error', description: 'Bootcamp tidak ditemukan', variant: 'destructive' });
navigate('/dashboard');
return;
}
setProduct(productData);
// Fetch accent color from settings
const { data: settings } = await supabase
.from('site_settings')
.select('brand_accent_color')
.single();
if (settings?.brand_accent_color) {
setAccentColor(settings.brand_accent_color);
}
const { data: accessData } = await supabase
.from('user_access')
.select('id')
.eq('user_id', user!.id)
.eq('product_id', productData.id)
.maybeSingle();
if (!accessData) {
toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke bootcamp ini', variant: 'destructive' });
navigate('/dashboard');
return;
}
const { data: modulesData } = await supabase
.from('bootcamp_modules')
.select(`
id,
title,
position,
bootcamp_lessons (
id,
title,
content,
video_url,
youtube_url,
embed_code,
duration_seconds,
position,
release_at,
chapters
)
`)
.eq('product_id', productData.id)
.order('position');
if (modulesData) {
const sortedModules = modulesData.map(m => ({
...m,
lessons: (m.bootcamp_lessons as Lesson[]).sort((a, b) => a.position - b.position)
}));
setModules(sortedModules);
// Select lesson based on URL parameter or default to first lesson
const allLessons = sortedModules.flatMap(m => m.lessons);
if (lessonId) {
// Find the lesson by ID from URL
const lessonFromUrl = allLessons.find(l => l.id === lessonId);
if (lessonFromUrl) {
setSelectedLesson(lessonFromUrl);
} else if (allLessons.length > 0) {
// If lesson not found, default to first lesson
setSelectedLesson(allLessons[0]);
}
} else if (allLessons.length > 0 && sortedModules[0].lessons.length > 0) {
// No lessonId in URL, select first lesson
setSelectedLesson(sortedModules[0].lessons[0]);
}
}
const { data: progressData } = await supabase
.from('lesson_progress')
.select('lesson_id, completed_at')
.eq('user_id', user!.id);
if (progressData) {
setProgress(progressData);
}
// Check if user has already reviewed this bootcamp
const { data: reviewData } = await supabase
.from('reviews')
.select('id, rating, title, body, is_approved, created_at')
.eq('user_id', user!.id)
.eq('product_id', productData.id)
.order('created_at', { ascending: false })
.limit(1);
if (reviewData && reviewData.length > 0) {
setUserReview(reviewData[0] as UserReview);
} else {
setUserReview(null);
}
setLoading(false);
};
const isLessonCompleted = (lessonId: string) => {
return progress.some(p => p.lesson_id === lessonId);
};
const handleSelectLesson = (lesson: Lesson) => {
setSelectedLesson(lesson);
// Update URL without full page reload
navigate(`/bootcamp/${slug}/${lesson.id}`);
};
const markAsCompleted = async () => {
if (!selectedLesson || !user || !product) return;
const { error } = await supabase
.from('lesson_progress')
.insert({ user_id: user.id, lesson_id: selectedLesson.id });
if (error) {
if (error.code === '23505') {
toast({ title: 'Info', description: 'Pelajaran sudah ditandai selesai' });
} else {
toast({ title: 'Error', description: 'Gagal menandai selesai', variant: 'destructive' });
}
return;
}
const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }];
setProgress(newProgress);
// Calculate completion percentage for notification
const completedCount = newProgress.length;
const completionPercent = Math.round((completedCount / totalLessons) * 100);
// Trigger progress notification at milestones
if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) {
try {
await supabase.functions.invoke('send-notification', {
body: {
template_key: 'bootcamp_progress',
recipient_email: user.email,
recipient_name: user.user_metadata?.name || 'Peserta',
variables: {
bootcamp_title: product.title,
progress_percent: completionPercent.toString(),
completed_lessons: completedCount.toString(),
total_lessons: totalLessons.toString(),
},
},
});
} catch (err) {
console.log('Progress notification skipped:', err);
}
}
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
goToNextLesson();
};
const goToNextLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex < allLessons.length - 1) {
setSelectedLesson(allLessons[currentIndex + 1]);
}
};
const goToPrevLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex > 0) {
setSelectedLesson(allLessons[currentIndex - 1]);
}
};
const getYouTubeEmbedUrl = (url: string): string => {
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
};
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
const activeSource = product?.video_source || 'youtube';
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
// Get video based on product's active source
const getVideoSource = () => {
if (activeSource === 'youtube') {
if (lesson.youtube_url && lesson.youtube_url.trim()) {
return {
type: 'youtube',
url: lesson.youtube_url,
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
};
} else if (lesson.video_url && lesson.video_url.trim()) {
// Fallback to old video_url for backward compatibility
return {
type: 'youtube',
url: lesson.video_url,
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
};
} else {
// Fallback to embed if YouTube not available
return lesson.embed_code && lesson.embed_code.trim() ? {
type: 'embed',
html: lesson.embed_code
} : null;
}
} else {
if (lesson.embed_code && lesson.embed_code.trim()) {
return {
type: 'embed',
html: lesson.embed_code
};
} else {
// Fallback to YouTube if embed not available
return lesson.youtube_url && lesson.youtube_url.trim() ? {
type: 'youtube',
url: lesson.youtube_url,
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
} : lesson.video_url && lesson.video_url.trim() ? {
type: 'youtube',
url: lesson.video_url,
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
} : null;
}
}
};
const video = getVideoSource();
// Show warning if no video available
if (!video) {
return (
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
<CardContent className="py-12 text-center">
<p className="text-destructive font-medium">Konten tidak tersedia</p>
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
</CardContent>
</Card>
);
}
// Render based on video type
if (video.type === 'embed') {
return (
<div className="mb-6">
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
<div dangerouslySetInnerHTML={{ __html: video.html }} />
</div>
{hasChapters && (
<div className="mt-4">
<TimelineChapters
chapters={lesson.chapters}
isYouTube={false}
currentTime={currentTime}
accentColor={accentColor}
/>
</div>
)}
</div>
);
}
// YouTube with chapters support
const isYouTube = video.type === 'youtube';
return (
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
<div className={hasChapters ? "lg:col-span-2" : ""}>
<VideoPlayerWithChapters
ref={playerRef}
videoUrl={video.url}
embedCode={lesson.embed_code}
chapters={lesson.chapters}
accentColor={accentColor}
onTimeUpdate={setCurrentTime}
/>
</div>
{hasChapters && (
<div className="lg:col-span-1">
<TimelineChapters
chapters={lesson.chapters}
isYouTube={isYouTube}
onChapterClick={(time) => {
if (playerRef.current) {
playerRef.current.jumpToTime(time);
}
}}
currentTime={currentTime}
accentColor={accentColor}
/>
</div>
)}
</div>
);
};
const completedCount = progress.length;
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
const renderSidebarContent = () => (
<div className="p-4">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
{module.title}
</h3>
<div className="space-y-1">
{module.lessons.map((lesson) => {
const isCompleted = isLessonCompleted(lesson.id);
const isSelected = selectedLesson?.id === lesson.id;
const isReleased = !lesson.release_at || new Date(lesson.release_at) <= new Date();
return (
<button
key={lesson.id}
onClick={() => {
if (isReleased) {
handleSelectLesson(lesson);
setMobileMenuOpen(false);
}
}}
disabled={!isReleased}
className={cn(
"w-full text-left px-3 py-2 rounded-none text-sm flex items-center gap-2 transition-colors",
isSelected ? "bg-primary text-primary-foreground" : "hover:bg-muted",
!isReleased && "opacity-50 cursor-not-allowed"
)}
>
{isCompleted ? (
<Check className="w-4 h-4 shrink-0 text-accent" />
) : (lesson.video_url?.trim() || lesson.youtube_url?.trim() || lesson.embed_code?.trim()) ? (
<Play className="w-4 h-4 shrink-0" />
) : (
<BookOpen className="w-4 h-4 shrink-0" />
)}
<span className="truncate flex-1">{lesson.title}</span>
{lesson.duration_seconds && (
<span className="text-xs text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
)}
</button>
);
})}
</div>
</div>
))}
</div>
);
if (authLoading || loading) {
return (
<div className="min-h-screen bg-background">
<div className="flex">
<div className="w-80 border-r border-border p-4 hidden md:block">
<Skeleton className="h-8 w-full mb-4" />
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full mb-2" />
))}
</div>
<div className="flex-1 p-8">
<Skeleton className="h-10 w-1/2 mb-4" />
<Skeleton className="aspect-video w-full" />
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b-2 border-border bg-card sticky top-0 z-50">
<div className="flex items-center justify-between px-4 h-16">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
<ChevronLeft className="w-4 h-4 mr-1" />
<span className="hidden sm:inline">Kembali ke Dashboard</span>
</Button>
<h1 className="text-lg md:text-xl font-bold truncate">{product?.title}</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground hidden sm:inline">
{completedCount} / {totalLessons} selesai
</span>
<div className="w-24 md:w-32 h-2 bg-muted rounded-none overflow-hidden border border-border">
<div
className="h-full bg-primary transition-all"
style={{ width: `${totalLessons > 0 ? (completedCount / totalLessons) * 100 : 0}%` }}
/>
</div>
{/* Mobile menu trigger */}
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="sm" className="md:hidden">
<Menu className="w-5 h-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80 p-0 border-r-2 border-border">
<div className="p-4 border-b-2 border-border font-bold">Kurikulum</div>
<div className="overflow-y-auto h-[calc(100vh-60px)]">
{renderSidebarContent()}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
<div className="flex">
{/* Desktop Sidebar */}
<aside className={cn(
"hidden md:block border-r-2 border-border bg-card transition-all overflow-y-auto h-[calc(100vh-64px)] sticky top-16",
sidebarOpen ? "w-80" : "w-0"
)}>
{sidebarOpen && renderSidebarContent()}
</aside>
{/* Toggle sidebar button */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="hidden md:flex absolute top-1/2 -translate-y-1/2 bg-card border-2 border-border rounded-r-none p-1 z-10"
style={{ left: sidebarOpen ? '320px' : '0' }}
>
{sidebarOpen ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
{/* Main content */}
<main className="flex-1 p-4 md:p-8 min-h-[calc(100vh-64px)] overflow-y-auto">
{selectedLesson ? (
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-2xl md:text-3xl font-bold">{selectedLesson.title}</h2>
{selectedLesson.duration_seconds && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
{formatDuration(selectedLesson.duration_seconds)}
</span>
)}
</div>
<VideoPlayer lesson={selectedLesson} />
{selectedLesson.content && (
<Card className="border-2 border-border mb-6">
<CardContent className="pt-6">
<div
className="prose prose-slate max-w-none"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(selectedLesson.content, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'a', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
'img', 'div', 'span', 'iframe', 'table', 'thead', 'tbody', 'tr', 'td', 'th'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'width', 'height', 'class', 'style',
'target', 'rel', 'title', 'id', 'data-*'],
ALLOW_DATA_ATTR: true
})
}}
/>
</CardContent>
</Card>
)}
<div className="flex items-center justify-between gap-4 flex-wrap">
<Button
variant="outline"
onClick={goToPrevLesson}
disabled={modules.flatMap(m => m.lessons).findIndex(l => l.id === selectedLesson.id) === 0}
className="border-2"
>
<ChevronLeft className="w-4 h-4 mr-2" />
Sebelumnya
</Button>
<Button
onClick={markAsCompleted}
disabled={isLessonCompleted(selectedLesson.id)}
className="shadow-sm"
>
{isLessonCompleted(selectedLesson.id) ? (
<>
<Check className="w-4 h-4 mr-2" />
Selesai
</>
) : (
'Tandai Selesai'
)}
</Button>
{isBootcampCompleted ? (
<Button onClick={() => setReviewModalOpen(true)} className="shadow-sm">
<Star className="w-4 h-4 mr-2" />
Beri Ulasan
</Button>
) : (
<Button
variant="outline"
onClick={goToNextLesson}
disabled={modules.flatMap(m => m.lessons).findIndex(l => l.id === selectedLesson.id) === modules.flatMap(m => m.lessons).length - 1}
className="border-2"
>
Selanjutnya
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
)}
</div>
{/* Bootcamp completion review prompt */}
{isBootcampCompleted && (
<Card className={`border-2 mt-6 ${userReview?.is_approved ? 'bg-gradient-to-br from-brand-accent/10 to-primary/10 border-brand-accent/30' : 'border-primary/20'}`}>
<CardContent className="py-6">
{userReview ? (
userReview.is_approved ? (
// Approved review - celebratory display
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="rounded-full bg-brand-accent p-2">
<CheckCircle className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-bold">Ulasan Anda Terbit!</h3>
<Badge className="bg-brand-accent text-white rounded-full">Disetujui</Badge>
</div>
<p className="text-sm text-muted-foreground">Terima kasih telah berbagi pengalaman Anda. Ulasan Anda membantu peserta lain!</p>
</div>
</div>
{/* User's review display */}
<div className="bg-background/50 backdrop-blur rounded-lg p-4 border border-brand-accent/20">
<div className="flex gap-0.5 mb-2">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
className={`w-5 h-5 ${i <= userReview.rating ? 'fill-brand-accent text-brand-accent' : 'text-muted-foreground'}`}
/>
))}
</div>
<h4 className="font-semibold text-base mb-1">{userReview.title}</h4>
{userReview.body && (
<p className="text-sm text-muted-foreground">{userReview.body}</p>
)}
</div>
<div className="text-xs text-muted-foreground">
Diterbitkan pada {new Date(userReview.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
</div>
</div>
) : (
// Pending review
<div className="flex items-center gap-3 text-muted-foreground">
<div className="rounded-full bg-amber-500/10 p-2">
<Clock className="w-5 h-5 text-amber-500" />
</div>
<div>
<p className="font-medium text-foreground">Ulasan Anda sedang ditinjau</p>
<p className="text-sm">Terima kasih! Ulasan akan muncul setelah disetujui admin.</p>
</div>
</div>
)
) : (
// No review yet - prompt to review
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="font-medium">🎉 Selamat menyelesaikan bootcamp!</p>
<p className="text-sm text-muted-foreground">Bagikan pengalaman Anda</p>
</div>
<Button onClick={() => setReviewModalOpen(true)} variant="outline" className="border-2">
<Star className="w-4 h-4 mr-2" />
Beri ulasan
</Button>
</div>
)}
</CardContent>
</Card>
)}
</div>
) : (
<div className="flex items-center justify-center h-full">
<Card className="border-2 border-border">
<CardContent className="py-12 text-center">
<BookOpen className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
{modules.length === 0 ? 'Belum ada pelajaran tersedia' : 'Pilih pelajaran untuk memulai'}
</p>
</CardContent>
</Card>
</div>
)}
</main>
</div>
{/* Review Modal */}
{user && product && (
<ReviewModal
open={reviewModalOpen}
onOpenChange={setReviewModalOpen}
userId={user.id}
productId={product.id}
type="bootcamp"
contextLabel={product.title}
onSuccess={() => {
// Refresh review data
const refreshReview = async () => {
const { data } = await supabase
.from('reviews')
.select('id, rating, title, body, is_approved, created_at')
.eq('user_id', user.id)
.eq('product_id', product.id)
.order('created_at', { ascending: false })
.limit(1);
if (data && data.length > 0) {
setUserReview(data[0] as UserReview);
}
};
refreshReview();
}}
/>
)}
</div>
);
}