Changes
This commit is contained in:
385
src/pages/Bootcamp.tsx
Normal file
385
src/pages/Bootcamp.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
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, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { ChevronLeft, ChevronRight, Check, Play, BookOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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;
|
||||
position: number;
|
||||
release_at: string | null;
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
lesson_id: string;
|
||||
completed_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 [hasAccess, setHasAccess] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/auth');
|
||||
} else if (user && slug) {
|
||||
checkAccessAndFetch();
|
||||
}
|
||||
}, [user, authLoading, slug]);
|
||||
|
||||
const checkAccessAndFetch = async () => {
|
||||
// First get the product
|
||||
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 not found', variant: 'destructive' });
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
setProduct(productData);
|
||||
|
||||
// Check access
|
||||
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: 'Access denied', description: 'You don\'t have access to this bootcamp', variant: 'destructive' });
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
setHasAccess(true);
|
||||
|
||||
// Fetch modules with lessons
|
||||
const { data: modulesData } = await supabase
|
||||
.from('bootcamp_modules')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
position,
|
||||
bootcamp_lessons (
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
video_url,
|
||||
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);
|
||||
|
||||
// Select first lesson
|
||||
if (sortedModules.length > 0 && sortedModules[0].lessons.length > 0) {
|
||||
setSelectedLesson(sortedModules[0].lessons[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch progress
|
||||
const { data: progressData } = await supabase
|
||||
.from('lesson_progress')
|
||||
.select('lesson_id, completed_at')
|
||||
.eq('user_id', user!.id);
|
||||
|
||||
if (progressData) {
|
||||
setProgress(progressData);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const isLessonCompleted = (lessonId: string) => {
|
||||
return progress.some(p => p.lesson_id === lessonId);
|
||||
};
|
||||
|
||||
const markAsCompleted = async () => {
|
||||
if (!selectedLesson || !user) 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: 'Already completed', description: 'This lesson is already marked as completed' });
|
||||
} else {
|
||||
toast({ title: 'Error', description: 'Failed to mark as completed', variant: 'destructive' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress([...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }]);
|
||||
toast({ title: 'Completed!', description: 'Lesson marked as completed' });
|
||||
|
||||
// Auto-advance to next lesson
|
||||
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) => {
|
||||
// Handle YouTube URLs
|
||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
if (youtubeMatch) {
|
||||
return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||
}
|
||||
// Handle Vimeo URLs
|
||||
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);
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex">
|
||||
<div className="w-80 border-r border-border p-4">
|
||||
<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 border-border bg-card">
|
||||
<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" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<h1 className="text-xl font-bold">{product?.title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{completedCount} / {totalLessons} completed
|
||||
</span>
|
||||
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${totalLessons > 0 ? (completedCount / totalLessons) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<aside className={cn(
|
||||
"border-r border-border bg-card transition-all overflow-y-auto h-[calc(100vh-64px)]",
|
||||
sidebarOpen ? "w-80" : "w-0"
|
||||
)}>
|
||||
{sidebarOpen && (
|
||||
<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={() => isReleased && setSelectedLesson(lesson)}
|
||||
disabled={!isReleased}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-md 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">{lesson.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Toggle sidebar button */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 bg-card border border-border rounded-r-md 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-8 h-[calc(100vh-64px)] overflow-y-auto">
|
||||
{selectedLesson ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold mb-6">{selectedLesson.title}</h2>
|
||||
|
||||
{selectedLesson.video_url && (
|
||||
<div className="aspect-video bg-muted rounded-lg overflow-hidden mb-6">
|
||||
<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">
|
||||
<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" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={markAsCompleted}
|
||||
disabled={isLessonCompleted(selectedLesson.id)}
|
||||
className="shadow-sm"
|
||||
>
|
||||
{isLessonCompleted(selectedLesson.id) ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Completed
|
||||
</>
|
||||
) : (
|
||||
'Mark as Completed'
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</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 ? 'No lessons available yet' : 'Select a lesson to begin'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user