This commit is contained in:
gpt-engineer-app[bot]
2025-12-18 17:15:45 +00:00
parent 6a69232261
commit de98ccfc49
6 changed files with 1009 additions and 496 deletions

View File

@@ -12,6 +12,7 @@ import ProductDetail from "./pages/ProductDetail";
import Checkout from "./pages/Checkout"; import Checkout from "./pages/Checkout";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import Admin from "./pages/Admin"; import Admin from "./pages/Admin";
import Bootcamp from "./pages/Bootcamp";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -32,6 +33,7 @@ const App = () => (
<Route path="/checkout" element={<Checkout />} /> <Route path="/checkout" element={<Checkout />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<Admin />} /> <Route path="/admin" element={<Admin />} />
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -0,0 +1,471 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from '@/hooks/use-toast';
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Module {
id: string;
title: string;
position: number;
}
interface Lesson {
id: string;
module_id: string;
title: string;
content: string | null;
video_url: string | null;
position: number;
release_at: string | null;
}
interface CurriculumEditorProps {
productId: string;
}
export function CurriculumEditor({ productId }: CurriculumEditorProps) {
const [modules, setModules] = useState<Module[]>([]);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [loading, setLoading] = useState(true);
const [moduleDialogOpen, setModuleDialogOpen] = useState(false);
const [editingModule, setEditingModule] = useState<Module | null>(null);
const [moduleTitle, setModuleTitle] = useState('');
const [lessonDialogOpen, setLessonDialogOpen] = useState(false);
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
const [lessonForm, setLessonForm] = useState({
module_id: '',
title: '',
content: '',
video_url: '',
release_at: '',
});
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
useEffect(() => {
fetchData();
}, [productId]);
const fetchData = async () => {
const [modulesRes, lessonsRes] = await Promise.all([
supabase
.from('bootcamp_modules')
.select('*')
.eq('product_id', productId)
.order('position'),
supabase
.from('bootcamp_lessons')
.select('*')
.order('position'),
]);
if (modulesRes.data) {
setModules(modulesRes.data);
// Expand all modules by default
setExpandedModules(new Set(modulesRes.data.map(m => m.id)));
}
if (lessonsRes.data) {
setLessons(lessonsRes.data);
}
setLoading(false);
};
const getLessonsForModule = (moduleId: string) => {
return lessons.filter(l => l.module_id === moduleId).sort((a, b) => a.position - b.position);
};
// Module CRUD
const handleNewModule = () => {
setEditingModule(null);
setModuleTitle('');
setModuleDialogOpen(true);
};
const handleEditModule = (module: Module) => {
setEditingModule(module);
setModuleTitle(module.title);
setModuleDialogOpen(true);
};
const handleSaveModule = async () => {
if (!moduleTitle.trim()) {
toast({ title: 'Error', description: 'Module title is required', variant: 'destructive' });
return;
}
if (editingModule) {
const { error } = await supabase
.from('bootcamp_modules')
.update({ title: moduleTitle })
.eq('id', editingModule.id);
if (error) {
toast({ title: 'Error', description: 'Failed to update module', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Module updated' });
setModuleDialogOpen(false);
fetchData();
}
} else {
const maxPosition = modules.length > 0 ? Math.max(...modules.map(m => m.position)) : 0;
const { error } = await supabase
.from('bootcamp_modules')
.insert({ product_id: productId, title: moduleTitle, position: maxPosition + 1 });
if (error) {
toast({ title: 'Error', description: 'Failed to create module', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Module created' });
setModuleDialogOpen(false);
fetchData();
}
}
};
const handleDeleteModule = async (moduleId: string) => {
if (!confirm('Delete this module and all its lessons?')) return;
const { error } = await supabase.from('bootcamp_modules').delete().eq('id', moduleId);
if (error) {
toast({ title: 'Error', description: 'Failed to delete module', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Module deleted' });
fetchData();
}
};
const moveModule = async (moduleId: string, direction: 'up' | 'down') => {
const index = modules.findIndex(m => m.id === moduleId);
if ((direction === 'up' && index === 0) || (direction === 'down' && index === modules.length - 1)) return;
const swapIndex = direction === 'up' ? index - 1 : index + 1;
const currentModule = modules[index];
const swapModule = modules[swapIndex];
await Promise.all([
supabase.from('bootcamp_modules').update({ position: swapModule.position }).eq('id', currentModule.id),
supabase.from('bootcamp_modules').update({ position: currentModule.position }).eq('id', swapModule.id),
]);
fetchData();
};
// Lesson CRUD
const handleNewLesson = (moduleId: string) => {
setEditingLesson(null);
setLessonForm({
module_id: moduleId,
title: '',
content: '',
video_url: '',
release_at: '',
});
setLessonDialogOpen(true);
};
const handleEditLesson = (lesson: Lesson) => {
setEditingLesson(lesson);
setLessonForm({
module_id: lesson.module_id,
title: lesson.title,
content: lesson.content || '',
video_url: lesson.video_url || '',
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
});
setLessonDialogOpen(true);
};
const handleSaveLesson = async () => {
if (!lessonForm.title.trim()) {
toast({ title: 'Error', description: 'Lesson title is required', variant: 'destructive' });
return;
}
const lessonData = {
module_id: lessonForm.module_id,
title: lessonForm.title,
content: lessonForm.content || null,
video_url: lessonForm.video_url || null,
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
};
if (editingLesson) {
const { error } = await supabase
.from('bootcamp_lessons')
.update(lessonData)
.eq('id', editingLesson.id);
if (error) {
toast({ title: 'Error', description: 'Failed to update lesson', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Lesson updated' });
setLessonDialogOpen(false);
fetchData();
}
} else {
const moduleLessons = getLessonsForModule(lessonForm.module_id);
const maxPosition = moduleLessons.length > 0 ? Math.max(...moduleLessons.map(l => l.position)) : 0;
const { error } = await supabase
.from('bootcamp_lessons')
.insert({ ...lessonData, position: maxPosition + 1 });
if (error) {
toast({ title: 'Error', description: 'Failed to create lesson', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Lesson created' });
setLessonDialogOpen(false);
fetchData();
}
}
};
const handleDeleteLesson = async (lessonId: string) => {
if (!confirm('Delete this lesson?')) return;
const { error } = await supabase.from('bootcamp_lessons').delete().eq('id', lessonId);
if (error) {
toast({ title: 'Error', description: 'Failed to delete lesson', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Lesson deleted' });
fetchData();
}
};
const moveLesson = async (lessonId: string, direction: 'up' | 'down') => {
const lesson = lessons.find(l => l.id === lessonId);
if (!lesson) return;
const moduleLessons = getLessonsForModule(lesson.module_id);
const index = moduleLessons.findIndex(l => l.id === lessonId);
if ((direction === 'up' && index === 0) || (direction === 'down' && index === moduleLessons.length - 1)) return;
const swapIndex = direction === 'up' ? index - 1 : index + 1;
const swapLesson = moduleLessons[swapIndex];
await Promise.all([
supabase.from('bootcamp_lessons').update({ position: swapLesson.position }).eq('id', lesson.id),
supabase.from('bootcamp_lessons').update({ position: lesson.position }).eq('id', swapLesson.id),
]);
fetchData();
};
const toggleModule = (moduleId: string) => {
const newExpanded = new Set(expandedModules);
if (newExpanded.has(moduleId)) {
newExpanded.delete(moduleId);
} else {
newExpanded.add(moduleId);
}
setExpandedModules(newExpanded);
};
if (loading) {
return <div className="text-muted-foreground">Loading curriculum...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Curriculum</h3>
<Button onClick={handleNewModule} size="sm" className="shadow-sm">
<Plus className="w-4 h-4 mr-2" />
Add Module
</Button>
</div>
{modules.length === 0 ? (
<Card className="border-2 border-dashed border-border">
<CardContent className="py-8 text-center">
<p className="text-muted-foreground mb-4">No modules yet. Create your first module to start building the curriculum.</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{modules.map((module, moduleIndex) => (
<Card key={module.id} className="border-2 border-border">
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<button
onClick={() => toggleModule(module.id)}
className="flex items-center gap-2 text-left"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
<CardTitle className="text-base">{module.title}</CardTitle>
<span className="text-sm text-muted-foreground">
({getLessonsForModule(module.id).length} lessons)
</span>
</button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => moveModule(module.id, 'up')}
disabled={moduleIndex === 0}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => moveModule(module.id, 'down')}
disabled={moduleIndex === modules.length - 1}
>
<ChevronDown className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleEditModule(module)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteModule(module.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
{expandedModules.has(module.id) && (
<CardContent className="pt-0">
<div className="space-y-2">
{getLessonsForModule(module.id).map((lesson, lessonIndex) => (
<div
key={lesson.id}
className="flex items-center justify-between p-2 bg-muted rounded-md"
>
<div className="flex items-center gap-2">
<GripVertical className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{lesson.title}</span>
{lesson.video_url && (
<span className="text-xs bg-secondary px-1.5 py-0.5 rounded">Video</span>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => moveLesson(lesson.id, 'up')}
disabled={lessonIndex === 0}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => moveLesson(lesson.id, 'down')}
disabled={lessonIndex === getLessonsForModule(module.id).length - 1}
>
<ChevronDown className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleEditLesson(lesson)}>
<Pencil className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteLesson(lesson.id)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => handleNewLesson(module.id)}
className="w-full border-dashed"
>
<Plus className="w-4 h-4 mr-2" />
Add Lesson
</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
{/* Module Dialog */}
<Dialog open={moduleDialogOpen} onOpenChange={setModuleDialogOpen}>
<DialogContent className="border-2 border-border">
<DialogHeader>
<DialogTitle>{editingModule ? 'Edit Module' : 'New Module'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={moduleTitle}
onChange={(e) => setModuleTitle(e.target.value)}
placeholder="Module title"
className="border-2"
/>
</div>
<Button onClick={handleSaveModule} className="w-full shadow-sm">
Save Module
</Button>
</div>
</DialogContent>
</Dialog>
{/* Lesson Dialog */}
<Dialog open={lessonDialogOpen} onOpenChange={setLessonDialogOpen}>
<DialogContent className="max-w-xl border-2 border-border">
<DialogHeader>
<DialogTitle>{editingLesson ? 'Edit Lesson' : 'New Lesson'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={lessonForm.title}
onChange={(e) => setLessonForm({ ...lessonForm, title: e.target.value })}
placeholder="Lesson title"
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Video URL</Label>
<Input
value={lessonForm.video_url}
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
placeholder="https://youtube.com/... or https://vimeo.com/..."
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Content (HTML)</Label>
<Textarea
value={lessonForm.content}
onChange={(e) => setLessonForm({ ...lessonForm, content: e.target.value })}
placeholder="Lesson content..."
rows={6}
className="border-2 font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>Release Date (optional)</Label>
<Input
type="date"
value={lessonForm.release_at}
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
className="border-2"
/>
</div>
<Button onClick={handleSaveLesson} className="w-full shadow-sm">
Save Lesson
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -7,41 +7,20 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Plus, Pencil, Trash2 } from 'lucide-react'; import { Plus, Pencil, Trash2 } from 'lucide-react';
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
interface Product { interface Product { id: string; title: string; slug: string; type: string; description: string; content: string; meeting_link: string | null; recording_url: string | null; price: number; sale_price: number | null; is_active: boolean; }
id: string;
title: string;
slug: string;
type: string;
description: string;
content: string;
meeting_link: string | null;
recording_url: string | null;
price: number;
sale_price: number | null;
is_active: boolean;
}
const emptyProduct = { const emptyProduct = { title: '', slug: '', type: 'consulting', description: '', content: '', meeting_link: '', recording_url: '', price: 0, sale_price: null as number | null, is_active: true };
title: '',
slug: '',
type: 'consulting',
description: '',
content: '',
meeting_link: '',
recording_url: '',
price: 0,
sale_price: null as number | null,
is_active: true,
};
export default function Admin() { export default function Admin() {
const { user, isAdmin, loading: authLoading } = useAuth(); const { user, isAdmin, loading: authLoading } = useAuth();
@@ -52,317 +31,111 @@ export default function Admin() {
const [editingProduct, setEditingProduct] = useState<Product | null>(null); const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [form, setForm] = useState(emptyProduct); const [form, setForm] = useState(emptyProduct);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState('details');
useEffect(() => { useEffect(() => {
if (!authLoading) { if (!authLoading) {
if (!user) { if (!user) navigate('/auth');
navigate('/auth'); else if (!isAdmin) { toast({ title: 'Access denied', description: 'Admin access required', variant: 'destructive' }); navigate('/dashboard'); }
} else if (!isAdmin) { else fetchProducts();
toast({ title: 'Access denied', description: 'Admin access required', variant: 'destructive' });
navigate('/dashboard');
} else {
fetchProducts();
}
} }
}, [user, isAdmin, authLoading, navigate]); }, [user, isAdmin, authLoading, navigate]);
const fetchProducts = async () => { const fetchProducts = async () => {
const { data, error } = await supabase const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false });
.from('products') if (!error && data) setProducts(data);
.select('*')
.order('created_at', { ascending: false });
if (!error && data) {
setProducts(data);
}
setLoading(false); setLoading(false);
}; };
const generateSlug = (title: string) => { const generateSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
};
const handleEdit = (product: Product) => { const handleEdit = (product: Product) => {
setEditingProduct(product); setEditingProduct(product);
setForm({ setForm({ title: product.title, slug: product.slug, type: product.type, description: product.description, content: product.content || '', meeting_link: product.meeting_link || '', recording_url: product.recording_url || '', price: product.price, sale_price: product.sale_price, is_active: product.is_active });
title: product.title, setActiveTab('details');
slug: product.slug,
type: product.type,
description: product.description,
content: product.content || '',
meeting_link: product.meeting_link || '',
recording_url: product.recording_url || '',
price: product.price,
sale_price: product.sale_price,
is_active: product.is_active,
});
setDialogOpen(true); setDialogOpen(true);
}; };
const handleNew = () => { const handleNew = () => { setEditingProduct(null); setForm(emptyProduct); setActiveTab('details'); setDialogOpen(true); };
setEditingProduct(null);
setForm(emptyProduct);
setDialogOpen(true);
};
const handleSave = async () => { const handleSave = async () => {
if (!form.title || !form.slug || form.price <= 0) { if (!form.title || !form.slug || form.price <= 0) { toast({ title: 'Validation error', description: 'Please fill in all required fields', variant: 'destructive' }); return; }
toast({ title: 'Validation error', description: 'Please fill in all required fields', variant: 'destructive' });
return;
}
setSaving(true); setSaving(true);
const productData = { title: form.title, slug: form.slug, type: form.type, description: form.description, content: form.content, meeting_link: form.meeting_link || null, recording_url: form.recording_url || null, price: form.price, sale_price: form.sale_price || null, is_active: form.is_active };
const productData = {
title: form.title,
slug: form.slug,
type: form.type,
description: form.description,
content: form.content,
meeting_link: form.meeting_link || null,
recording_url: form.recording_url || null,
price: form.price,
sale_price: form.sale_price || null,
is_active: form.is_active,
};
if (editingProduct) { if (editingProduct) {
const { error } = await supabase const { error } = await supabase.from('products').update(productData).eq('id', editingProduct.id);
.from('products') if (error) toast({ title: 'Error', description: 'Failed to update product', variant: 'destructive' });
.update(productData) else { toast({ title: 'Success', description: 'Product updated' }); setDialogOpen(false); fetchProducts(); }
.eq('id', editingProduct.id);
if (error) {
toast({ title: 'Error', description: 'Failed to update product', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Product updated' });
setDialogOpen(false);
fetchProducts();
}
} else { } else {
const { error } = await supabase const { error } = await supabase.from('products').insert(productData);
.from('products') if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
.insert(productData); else { toast({ title: 'Success', description: 'Product created' }); setDialogOpen(false); fetchProducts(); }
if (error) {
toast({ title: 'Error', description: error.message, variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Product created' });
setDialogOpen(false);
fetchProducts();
}
} }
setSaving(false); setSaving(false);
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this product?')) return; if (!confirm('Are you sure you want to delete this product?')) return;
const { error } = await supabase.from('products').delete().eq('id', id); const { error } = await supabase.from('products').delete().eq('id', id);
if (error) toast({ title: 'Error', description: 'Failed to delete product', variant: 'destructive' });
if (error) { else { toast({ title: 'Success', description: 'Product deleted' }); fetchProducts(); }
toast({ title: 'Error', description: 'Failed to delete product', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Product deleted' });
fetchProducts();
}
}; };
if (authLoading || loading) { if (authLoading || loading) return (<Layout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/3 mb-8" /><Skeleton className="h-64 w-full" /></div></Layout>);
return (
<Layout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<Skeleton className="h-64 w-full" />
</div>
</Layout>
);
}
return ( return (
<Layout> <Layout>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div> <div><h1 className="text-4xl font-bold">Admin Panel</h1><p className="text-muted-foreground">Manage your products</p></div>
<h1 className="text-4xl font-bold">Admin Panel</h1>
<p className="text-muted-foreground">Manage your products</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild><Button onClick={handleNew} className="shadow-sm"><Plus className="w-4 h-4 mr-2" />Add Product</Button></DialogTrigger>
<Button onClick={handleNew} className="shadow-sm"> <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto border-2 border-border">
<Plus className="w-4 h-4 mr-2" /> <DialogHeader><DialogTitle>{editingProduct ? 'Edit Product' : 'New Product'}</DialogTitle></DialogHeader>
Add Product <Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
</Button> <TabsList className="border-2 border-border">
</DialogTrigger> <TabsTrigger value="details">Details</TabsTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto border-2 border-border"> {editingProduct && form.type === 'bootcamp' && <TabsTrigger value="curriculum">Curriculum</TabsTrigger>}
<DialogHeader> </TabsList>
<DialogTitle>{editingProduct ? 'Edit Product' : 'New Product'}</DialogTitle> <TabsContent value="details" className="space-y-4 py-4">
</DialogHeader> <div className="grid grid-cols-2 gap-4">
<div className="space-y-4 py-4"> <div className="space-y-2"><Label>Title *</Label><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" /></div>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-2"><Label>Slug *</Label><Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" /></div>
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={form.title}
onChange={(e) => {
setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) });
}}
className="border-2"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2"><Label>Type</Label><Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}><SelectTrigger className="border-2"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="consulting">Consulting</SelectItem><SelectItem value="webinar">Webinar</SelectItem><SelectItem value="bootcamp">Bootcamp</SelectItem></SelectContent></Select></div>
<Label>Slug *</Label> <div className="space-y-2"><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="border-2" rows={2} /></div>
<Input <div className="space-y-2"><Label>Content (HTML)</Label><Textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} className="border-2 font-mono text-sm" rows={6} /></div>
value={form.slug} <div className="grid grid-cols-2 gap-4">
onChange={(e) => setForm({ ...form, slug: e.target.value })} <div className="space-y-2"><Label>Meeting Link</Label><Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" /></div>
className="border-2" <div className="space-y-2"><Label>Recording URL</Label><Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" /></div>
/>
</div> </div>
</div> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Price *</Label><Input type="number" value={form.price} onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} className="border-2" /></div>
<div className="space-y-2"> <div className="space-y-2"><Label>Sale Price</Label><Input type="number" value={form.sale_price || ''} onChange={(e) => setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })} placeholder="Leave empty if no sale" className="border-2" /></div>
<Label>Type</Label>
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
<SelectTrigger className="border-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="consulting">Consulting</SelectItem>
<SelectItem value="webinar">Webinar</SelectItem>
<SelectItem value="bootcamp">Bootcamp</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="border-2"
rows={2}
/>
</div>
<div className="space-y-2">
<Label>Content (HTML)</Label>
<Textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
className="border-2 font-mono text-sm"
rows={6}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Meeting Link</Label>
<Input
value={form.meeting_link}
onChange={(e) => setForm({ ...form, meeting_link: e.target.value })}
placeholder="https://meet.google.com/..."
className="border-2"
/>
</div> </div>
<div className="space-y-2"> <div className="flex items-center gap-2"><Switch checked={form.is_active} onCheckedChange={(checked) => setForm({ ...form, is_active: checked })} /><Label>Active</Label></div>
<Label>Recording URL</Label> <Button onClick={handleSave} className="w-full shadow-sm" disabled={saving}>{saving ? 'Saving...' : 'Save Product'}</Button>
<Input </TabsContent>
value={form.recording_url} {editingProduct && form.type === 'bootcamp' && <TabsContent value="curriculum" className="py-4"><CurriculumEditor productId={editingProduct.id} /></TabsContent>}
onChange={(e) => setForm({ ...form, recording_url: e.target.value })} </Tabs>
placeholder="https://youtube.com/..."
className="border-2"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Price *</Label>
<Input
type="number"
value={form.price}
onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })}
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Sale Price</Label>
<Input
type="number"
value={form.sale_price || ''}
onChange={(e) => setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })}
placeholder="Leave empty if no sale"
className="border-2"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={form.is_active}
onCheckedChange={(checked) => setForm({ ...form, is_active: checked })}
/>
<Label>Active</Label>
</div>
<Button onClick={handleSave} className="w-full shadow-sm" disabled={saving}>
{saving ? 'Saving...' : 'Save Product'}
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader><TableRow><TableHead>Title</TableHead><TableHead>Type</TableHead><TableHead>Price</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead></TableRow></TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Type</TableHead>
<TableHead>Price</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody> <TableBody>
{products.map((product) => ( {products.map((product) => (
<TableRow key={product.id}> <TableRow key={product.id}>
<TableCell className="font-medium">{product.title}</TableCell> <TableCell className="font-medium">{product.title}</TableCell>
<TableCell className="capitalize">{product.type}</TableCell> <TableCell className="capitalize">{product.type}</TableCell>
<TableCell> <TableCell>{product.sale_price ? <span><span className="font-bold">${product.sale_price}</span><span className="text-muted-foreground line-through ml-2">${product.price}</span></span> : <span className="font-bold">${product.price}</span>}</TableCell>
{product.sale_price ? ( <TableCell><span className={product.is_active ? 'text-foreground' : 'text-muted-foreground'}>{product.is_active ? 'Active' : 'Inactive'}</span></TableCell>
<span> <TableCell className="text-right"><Button variant="ghost" size="sm" onClick={() => handleEdit(product)}><Pencil className="w-4 h-4" /></Button><Button variant="ghost" size="sm" onClick={() => handleDelete(product.id)}><Trash2 className="w-4 h-4" /></Button></TableCell>
<span className="font-bold">${product.sale_price}</span>
<span className="text-muted-foreground line-through ml-2">${product.price}</span>
</span>
) : (
<span className="font-bold">${product.price}</span>
)}
</TableCell>
<TableCell>
<span className={product.is_active ? 'text-foreground' : 'text-muted-foreground'}>
{product.is_active ? 'Active' : 'Inactive'}
</span>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(product.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow> </TableRow>
))} ))}
{products.length === 0 && ( {products.length === 0 && <TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No products yet. Create your first product!</TableCell></TableRow>}
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No products yet. Create your first product!
</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
@@ -370,4 +143,4 @@ export default function Admin() {
</div> </div>
</Layout> </Layout>
); );
} }

385
src/pages/Bootcamp.tsx Normal file
View 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>
);
}

View File

@@ -8,28 +8,10 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ExternalLink, Video, Calendar } from 'lucide-react'; import { Video, Calendar, BookOpen, ArrowRight } from 'lucide-react';
interface UserAccess { interface UserAccess { id: string; granted_at: string; expires_at: string | null; product: { id: string; title: string; slug: string; type: string; meeting_link: string | null; recording_url: string | null; description: string; }; }
id: string; interface Order { id: string; total_amount: number; status: string; created_at: string; }
granted_at: string;
expires_at: string | null;
product: {
id: string;
title: string;
type: string;
meeting_link: string | null;
recording_url: string | null;
description: string;
};
}
interface Order {
id: string;
total_amount: number;
status: string;
created_at: string;
}
export default function Dashboard() { export default function Dashboard() {
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
@@ -39,168 +21,54 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) navigate('/auth');
navigate('/auth'); else if (user) fetchData();
} else if (user) {
fetchData();
}
}, [user, authLoading, navigate]); }, [user, authLoading, navigate]);
const fetchData = async () => { const fetchData = async () => {
const [accessRes, ordersRes] = await Promise.all([ const [accessRes, ordersRes] = await Promise.all([
supabase supabase.from('user_access').select(`id, granted_at, expires_at, product:products (id, title, slug, type, meeting_link, recording_url, description)`).eq('user_id', user!.id),
.from('user_access') supabase.from('orders').select('*').eq('user_id', user!.id).order('created_at', { ascending: false })
.select(`
id,
granted_at,
expires_at,
product:products (
id,
title,
type,
meeting_link,
recording_url,
description
)
`)
.eq('user_id', user!.id),
supabase
.from('orders')
.select('*')
.eq('user_id', user!.id)
.order('created_at', { ascending: false })
]); ]);
if (accessRes.data) setAccess(accessRes.data as unknown as UserAccess[]);
if (accessRes.data) { if (ordersRes.data) setOrders(ordersRes.data);
setAccess(accessRes.data as unknown as UserAccess[]);
}
if (ordersRes.data) {
setOrders(ordersRes.data);
}
setLoading(false); setLoading(false);
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) { case 'paid': return 'bg-accent'; case 'pending': return 'bg-secondary'; case 'cancelled': return 'bg-destructive'; case 'refunded': return 'bg-muted'; default: return 'bg-secondary'; }
case 'paid': return 'bg-accent'; };
case 'pending': return 'bg-secondary';
case 'cancelled': return 'bg-destructive'; const renderAccessActions = (item: UserAccess) => {
case 'refunded': return 'bg-muted'; switch (item.product.type) {
default: return 'bg-secondary'; case 'consulting': return (<Button asChild variant="outline" className="border-2"><a href={item.product.meeting_link || '#'} target="_blank" rel="noopener noreferrer"><Calendar className="w-4 h-4 mr-2" />Book Session</a></Button>);
case 'webinar': return (<div className="flex gap-2">{item.product.meeting_link && <Button asChild variant="outline" className="border-2"><a href={item.product.meeting_link} target="_blank" rel="noopener noreferrer"><Video className="w-4 h-4 mr-2" />Join Live</a></Button>}{item.product.recording_url && <Button asChild variant="outline" className="border-2"><a href={item.product.recording_url} target="_blank" rel="noopener noreferrer"><Video className="w-4 h-4 mr-2" />Watch Recording</a></Button>}</div>);
case 'bootcamp': return (<Button onClick={() => navigate(`/bootcamp/${item.product.slug}`)} className="shadow-sm"><BookOpen className="w-4 h-4 mr-2" />Continue Bootcamp<ArrowRight className="w-4 h-4 ml-2" /></Button>);
default: return null;
} }
}; };
if (authLoading || loading) { if (authLoading || loading) return (<Layout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/3 mb-8" /><div className="grid gap-4">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}</div></div></Layout>);
return (
<Layout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<div className="grid gap-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
</div>
</Layout>
);
}
return ( return (
<Layout> <Layout>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-2">Dashboard</h1> <h1 className="text-4xl font-bold mb-2">Dashboard</h1>
<p className="text-muted-foreground mb-8">Manage your purchases and access your content</p> <p className="text-muted-foreground mb-8">Manage your purchases and access your content</p>
<Tabs defaultValue="access" className="space-y-6"> <Tabs defaultValue="access" className="space-y-6">
<TabsList className="border-2 border-border"> <TabsList className="border-2 border-border"><TabsTrigger value="access">My Access</TabsTrigger><TabsTrigger value="orders">Order History</TabsTrigger></TabsList>
<TabsTrigger value="access">My Access</TabsTrigger>
<TabsTrigger value="orders">Order History</TabsTrigger>
</TabsList>
<TabsContent value="access"> <TabsContent value="access">
{access.length === 0 ? ( {access.length === 0 ? (<Card className="border-2 border-border"><CardContent className="py-12 text-center"><p className="text-muted-foreground mb-4">You don't have access to any products yet</p><Button onClick={() => navigate('/products')} variant="outline" className="border-2">Browse Products</Button></CardContent></Card>) : (
<Card className="border-2 border-border"> <div className="grid gap-4">{access.map((item) => (<Card key={item.id} className="border-2 border-border"><CardHeader><div className="flex items-start justify-between"><div><CardTitle>{item.product.title}</CardTitle><CardDescription className="capitalize">{item.product.type}</CardDescription></div><Badge className="bg-accent">Active</Badge></div></CardHeader><CardContent><p className="text-muted-foreground mb-4">{item.product.description}</p>{renderAccessActions(item)}</CardContent></Card>))}</div>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground mb-4">You don't have access to any products yet</p>
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
Browse Products
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{access.map((item) => (
<Card key={item.id} className="border-2 border-border">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>{item.product.title}</CardTitle>
<CardDescription className="capitalize">{item.product.type}</CardDescription>
</div>
<Badge className="bg-accent">Active</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">{item.product.description}</p>
<div className="flex gap-2">
{item.product.meeting_link && (
<Button asChild variant="outline" className="border-2">
<a href={item.product.meeting_link} target="_blank" rel="noopener noreferrer">
<Calendar className="w-4 h-4 mr-2" />
Join Meeting
</a>
</Button>
)}
{item.product.recording_url && (
<Button asChild variant="outline" className="border-2">
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
<Video className="w-4 h-4 mr-2" />
Watch Recording
</a>
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="orders"> <TabsContent value="orders">
{orders.length === 0 ? ( {orders.length === 0 ? (<Card className="border-2 border-border"><CardContent className="py-12 text-center"><p className="text-muted-foreground">No orders yet</p></CardContent></Card>) : (
<Card className="border-2 border-border"> <div className="space-y-4">{orders.map((order) => (<Card key={order.id} className="border-2 border-border"><CardContent className="py-4"><div className="flex items-center justify-between"><div><p className="font-mono text-sm text-muted-foreground">{order.id.slice(0, 8)}</p><p className="text-sm text-muted-foreground">{new Date(order.created_at).toLocaleDateString()}</p></div><div className="flex items-center gap-4"><Badge className={getStatusColor(order.status)}>{order.status}</Badge><span className="font-bold">${order.total_amount}</span></div></div></CardContent></Card>))}</div>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No orders yet</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{orders.map((order) => (
<Card key={order.id} className="border-2 border-border">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<p className="font-mono text-sm text-muted-foreground">
{order.id.slice(0, 8)}
</p>
<p className="text-sm text-muted-foreground">
{new Date(order.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-4">
<Badge className={getStatusColor(order.status)}>{order.status}</Badge>
<span className="font-bold">${order.total_amount}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)} )}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
</Layout> </Layout>
); );
} }

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useCart } from '@/contexts/CartContext'; import { useCart } from '@/contexts/CartContext';
import { useAuth } from '@/hooks/useAuth';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Video, Calendar, BookOpen } from 'lucide-react';
interface Product { interface Product {
id: string; id: string;
@@ -18,18 +20,33 @@ interface Product {
content: string; content: string;
price: number; price: number;
sale_price: number | null; sale_price: number | null;
meeting_link: string | null;
recording_url: string | null;
created_at: string;
} }
export default function ProductDetail() { export default function ProductDetail() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const [product, setProduct] = useState<Product | null>(null); const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasAccess, setHasAccess] = useState(false);
const [checkingAccess, setCheckingAccess] = useState(true);
const { addItem, items } = useCart(); const { addItem, items } = useCart();
const { user } = useAuth();
useEffect(() => { useEffect(() => {
if (slug) fetchProduct(); if (slug) fetchProduct();
}, [slug]); }, [slug]);
useEffect(() => {
if (product && user) {
checkUserAccess();
} else {
setCheckingAccess(false);
}
}, [product, user]);
const fetchProduct = async () => { const fetchProduct = async () => {
const { data, error } = await supabase const { data, error } = await supabase
.from('products') .from('products')
@@ -46,42 +63,62 @@ export default function ProductDetail() {
setLoading(false); setLoading(false);
}; };
const checkUserAccess = async () => {
if (!product || !user) return;
const { data } = await supabase
.from('user_access')
.select('id')
.eq('user_id', user.id)
.eq('product_id', product.id)
.maybeSingle();
setHasAccess(!!data);
setCheckingAccess(false);
};
const handleAddToCart = () => { const handleAddToCart = () => {
if (!product) return; if (!product) return;
addItem({ addItem({ id: product.id, title: product.title, price: product.price, sale_price: product.sale_price, type: product.type });
id: product.id,
title: product.title,
price: product.price,
sale_price: product.sale_price,
type: product.type,
});
toast({ title: 'Added to cart', description: `${product.title} has been added to your cart` }); toast({ title: 'Added to cart', description: `${product.title} has been added to your cart` });
}; };
const isInCart = product ? items.some(item => item.id === product.id) : false; const isInCart = product ? items.some(item => item.id === product.id) : false;
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;
};
if (loading) { if (loading) {
return ( return (<Layout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/2 mb-4" /><Skeleton className="h-6 w-1/4 mb-8" /><Skeleton className="h-64 w-full" /></div></Layout>);
<Layout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/2 mb-4" />
<Skeleton className="h-6 w-1/4 mb-8" />
<Skeleton className="h-64 w-full" />
</div>
</Layout>
);
} }
if (!product) { if (!product) {
return ( return (<Layout><div className="container mx-auto px-4 py-8 text-center"><h1 className="text-2xl font-bold">Product not found</h1></div></Layout>);
<Layout>
<div className="container mx-auto px-4 py-8 text-center">
<h1 className="text-2xl font-bold">Product not found</h1>
</div>
</Layout>
);
} }
const renderActionButtons = () => {
if (checkingAccess) return <Skeleton className="h-10 w-40" />;
if (!hasAccess) {
return (<Button onClick={handleAddToCart} disabled={isInCart} size="lg" className="shadow-sm">{isInCart ? 'Already in Cart' : 'Add to Cart'}</Button>);
}
switch (product.type) {
case 'consulting':
return (<Button asChild size="lg" className="shadow-sm"><a href={product.meeting_link || '#'} target="_blank" rel="noopener noreferrer"><Calendar className="w-4 h-4 mr-2" />Book Consulting Session</a></Button>);
case 'webinar':
if (product.recording_url) {
return (<div className="aspect-video bg-muted rounded-lg overflow-hidden"><iframe src={getVideoEmbed(product.recording_url)} className="w-full h-full" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen /></div>);
}
return product.meeting_link ? (<Button asChild size="lg" className="shadow-sm"><a href={product.meeting_link} target="_blank" rel="noopener noreferrer"><Video className="w-4 h-4 mr-2" />Join Live Webinar</a></Button>) : <Badge variant="secondary">Recording coming soon</Badge>;
case 'bootcamp':
return (<Button onClick={() => navigate(`/bootcamp/${product.slug}`)} size="lg" className="shadow-sm"><BookOpen className="w-4 h-4 mr-2" />Start Bootcamp</Button>);
default:
return null;
}
};
return ( return (
<Layout> <Layout>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -89,41 +126,18 @@ export default function ProductDetail() {
<div className="flex items-start justify-between mb-6"> <div className="flex items-start justify-between mb-6">
<div> <div>
<h1 className="text-4xl font-bold mb-2">{product.title}</h1> <h1 className="text-4xl font-bold mb-2">{product.title}</h1>
<Badge className="bg-secondary">{product.type}</Badge> <Badge className="bg-secondary capitalize">{product.type}</Badge>
{hasAccess && <Badge className="bg-accent ml-2">You have access</Badge>}
</div> </div>
<div className="text-right"> <div className="text-right">
{product.sale_price ? ( {product.sale_price ? (<div><span className="text-3xl font-bold">${product.sale_price}</span><span className="text-muted-foreground line-through ml-2">${product.price}</span></div>) : (<span className="text-3xl font-bold">${product.price}</span>)}
<div>
<span className="text-3xl font-bold">${product.sale_price}</span>
<span className="text-muted-foreground line-through ml-2">${product.price}</span>
</div>
) : (
<span className="text-3xl font-bold">${product.price}</span>
)}
</div> </div>
</div> </div>
<p className="text-lg text-muted-foreground mb-6">{product.description}</p> <p className="text-lg text-muted-foreground mb-6">{product.description}</p>
<Card className="border-2 border-border mb-6"><CardContent className="pt-6"><div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: product.content || '<p>No content available</p>' }} /></CardContent></Card>
<Card className="border-2 border-border mb-6"> {renderActionButtons()}
<CardContent className="pt-6">
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: product.content || '<p>No content available</p>' }}
/>
</CardContent>
</Card>
<Button
onClick={handleAddToCart}
disabled={isInCart}
size="lg"
className="shadow-sm"
>
{isInCart ? 'Already in Cart' : 'Add to Cart'}
</Button>
</div> </div>
</div> </div>
</Layout> </Layout>
); );
} }