Fixes "Badge is not defined" error in bootcamp focus page. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
566 lines
21 KiB
TypeScript
566 lines
21 KiB
TypeScript
import { useEffect, useState } 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';
|
|
|
|
interface Product {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
}
|
|
|
|
interface Module {
|
|
id: string;
|
|
title: string;
|
|
position: number;
|
|
lessons: Lesson[];
|
|
}
|
|
|
|
interface Lesson {
|
|
id: string;
|
|
title: string;
|
|
content: string | null;
|
|
video_url: string | null;
|
|
duration_seconds: number | null;
|
|
position: number;
|
|
release_at: string | null;
|
|
}
|
|
|
|
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 } = useParams<{ slug: 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);
|
|
|
|
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')
|
|
.eq('slug', slug)
|
|
.eq('type', 'bootcamp')
|
|
.maybeSingle();
|
|
|
|
if (productError || !productData) {
|
|
toast({ title: 'Error', description: 'Bootcamp tidak ditemukan', variant: 'destructive' });
|
|
navigate('/dashboard');
|
|
return;
|
|
}
|
|
|
|
setProduct(productData);
|
|
|
|
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,
|
|
duration_seconds,
|
|
position,
|
|
release_at
|
|
)
|
|
`)
|
|
.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);
|
|
|
|
if (sortedModules.length > 0 && sortedModules[0].lessons.length > 0) {
|
|
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 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 getVideoEmbed = (url: string) => {
|
|
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
|
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
|
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
|
|
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
|
return url;
|
|
};
|
|
|
|
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) {
|
|
setSelectedLesson(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 ? (
|
|
<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>
|
|
|
|
{selectedLesson.video_url && (
|
|
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
|
<iframe
|
|
src={getVideoEmbed(selectedLesson.video_url)}
|
|
className="w-full h-full"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowFullScreen
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedLesson.content && (
|
|
<Card className="border-2 border-border mb-6">
|
|
<CardContent className="pt-6">
|
|
<div
|
|
className="prose max-w-none"
|
|
dangerouslySetInnerHTML={{ __html: selectedLesson.content }}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|