Changes
This commit is contained in:
@@ -12,6 +12,7 @@ import ProductDetail from "./pages/ProductDetail";
|
||||
import Checkout from "./pages/Checkout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Admin from "./pages/Admin";
|
||||
import Bootcamp from "./pages/Bootcamp";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -32,6 +33,7 @@ const App = () => (
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
471
src/components/admin/CurriculumEditor.tsx
Normal file
471
src/components/admin/CurriculumEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,41 +7,20 @@ 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 { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
||||
|
||||
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;
|
||||
}
|
||||
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; }
|
||||
|
||||
const emptyProduct = {
|
||||
title: '',
|
||||
slug: '',
|
||||
type: 'consulting',
|
||||
description: '',
|
||||
content: '',
|
||||
meeting_link: '',
|
||||
recording_url: '',
|
||||
price: 0,
|
||||
sale_price: null as number | null,
|
||||
is_active: true,
|
||||
};
|
||||
const emptyProduct = { 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() {
|
||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||
@@ -52,317 +31,111 @@ export default function Admin() {
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [form, setForm] = useState(emptyProduct);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) {
|
||||
navigate('/auth');
|
||||
} else if (!isAdmin) {
|
||||
toast({ title: 'Access denied', description: 'Admin access required', variant: 'destructive' });
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
fetchProducts();
|
||||
}
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) { toast({ title: 'Access denied', description: 'Admin access required', variant: 'destructive' }); navigate('/dashboard'); }
|
||||
else fetchProducts();
|
||||
}
|
||||
}, [user, isAdmin, authLoading, navigate]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
setProducts(data);
|
||||
}
|
||||
const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false });
|
||||
if (!error && data) setProducts(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const generateSlug = (title: string) => {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
};
|
||||
const generateSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
|
||||
const handleEdit = (product: Product) => {
|
||||
setEditingProduct(product);
|
||||
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,
|
||||
});
|
||||
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 });
|
||||
setActiveTab('details');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingProduct(null);
|
||||
setForm(emptyProduct);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const handleNew = () => { setEditingProduct(null); setForm(emptyProduct); setActiveTab('details'); setDialogOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.title || !form.slug || form.price <= 0) {
|
||||
toast({ title: 'Validation error', description: 'Please fill in all required fields', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.title || !form.slug || form.price <= 0) { toast({ title: 'Validation error', description: 'Please fill in all required fields', variant: 'destructive' }); return; }
|
||||
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) {
|
||||
const { error } = await supabase
|
||||
.from('products')
|
||||
.update(productData)
|
||||
.eq('id', editingProduct.id);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to update product', variant: 'destructive' });
|
||||
const { error } = await supabase.from('products').update(productData).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 {
|
||||
toast({ title: 'Success', description: 'Product updated' });
|
||||
setDialogOpen(false);
|
||||
fetchProducts();
|
||||
const { error } = await supabase.from('products').insert(productData);
|
||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
else { toast({ title: 'Success', description: 'Product created' }); setDialogOpen(false); fetchProducts(); }
|
||||
}
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('products')
|
||||
.insert(productData);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Product created' });
|
||||
setDialogOpen(false);
|
||||
fetchProducts();
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this product?')) return;
|
||||
|
||||
const { error } = await supabase.from('products').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to delete product', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Product deleted' });
|
||||
fetchProducts();
|
||||
}
|
||||
if (error) toast({ title: 'Error', description: 'Failed to delete product', variant: 'destructive' });
|
||||
else { toast({ title: 'Success', description: 'Product deleted' }); fetchProducts(); }
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">Admin Panel</h1>
|
||||
<p className="text-muted-foreground">Manage your products</p>
|
||||
</div>
|
||||
<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}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={handleNew} className="shadow-sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Product
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto border-2 border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProduct ? 'Edit Product' : 'New Product'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<DialogTrigger asChild><Button onClick={handleNew} className="shadow-sm"><Plus className="w-4 h-4 mr-2" />Add Product</Button></DialogTrigger>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto border-2 border-border">
|
||||
<DialogHeader><DialogTitle>{editingProduct ? 'Edit Product' : 'New Product'}</DialogTitle></DialogHeader>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
|
||||
<TabsList className="border-2 border-border">
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
{editingProduct && form.type === 'bootcamp' && <TabsTrigger value="curriculum">Curriculum</TabsTrigger>}
|
||||
</TabsList>
|
||||
<TabsContent value="details" className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-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 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="space-y-2"><Label>Slug *</Label><Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" /></div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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="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>
|
||||
<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 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 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 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 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 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>
|
||||
</TabsContent>
|
||||
{editingProduct && form.type === 'bootcamp' && <TabsContent value="curriculum" className="py-4"><CurriculumEditor productId={editingProduct.id} /></TabsContent>}
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableHeader><TableRow><TableHead>Title</TableHead><TableHead>Type</TableHead><TableHead>Price</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{products.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="font-medium">{product.title}</TableCell>
|
||||
<TableCell className="capitalize">{product.type}</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No products yet. Create your first product!
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{products.length === 0 && <TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No products yet. Create your first product!</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,28 +8,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
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 {
|
||||
id: 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;
|
||||
}
|
||||
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; }; }
|
||||
interface Order { id: string; total_amount: number; status: string; created_at: string; }
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
@@ -39,164 +21,50 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/auth');
|
||||
} else if (user) {
|
||||
fetchData();
|
||||
}
|
||||
if (!authLoading && !user) navigate('/auth');
|
||||
else if (user) fetchData();
|
||||
}, [user, authLoading, navigate]);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [accessRes, ordersRes] = await Promise.all([
|
||||
supabase
|
||||
.from('user_access')
|
||||
.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 })
|
||||
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),
|
||||
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 (ordersRes.data) {
|
||||
setOrders(ordersRes.data);
|
||||
}
|
||||
if (accessRes.data) setAccess(accessRes.data as unknown as UserAccess[]);
|
||||
if (ordersRes.data) setOrders(ordersRes.data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
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';
|
||||
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'; }
|
||||
};
|
||||
|
||||
const renderAccessActions = (item: UserAccess) => {
|
||||
switch (item.product.type) {
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
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">
|
||||
<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>
|
||||
|
||||
<Tabs defaultValue="access" className="space-y-6">
|
||||
<TabsList className="border-2 border-border">
|
||||
<TabsTrigger value="access">My Access</TabsTrigger>
|
||||
<TabsTrigger value="orders">Order History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsList className="border-2 border-border"><TabsTrigger value="access">My Access</TabsTrigger><TabsTrigger value="orders">Order History</TabsTrigger></TabsList>
|
||||
<TabsContent value="access">
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
{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>) : (
|
||||
<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>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders">
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
{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>) : (
|
||||
<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>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Video, Calendar, BookOpen } from 'lucide-react';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
@@ -18,18 +20,33 @@ interface Product {
|
||||
content: string;
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function ProductDetail() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasAccess, setHasAccess] = useState(false);
|
||||
const [checkingAccess, setCheckingAccess] = useState(true);
|
||||
const { addItem, items } = useCart();
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) fetchProduct();
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (product && user) {
|
||||
checkUserAccess();
|
||||
} else {
|
||||
setCheckingAccess(false);
|
||||
}
|
||||
}, [product, user]);
|
||||
|
||||
const fetchProduct = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
@@ -46,42 +63,62 @@ export default function ProductDetail() {
|
||||
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 = () => {
|
||||
if (!product) return;
|
||||
addItem({
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
type: product.type,
|
||||
});
|
||||
addItem({ 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` });
|
||||
};
|
||||
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
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>);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
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>
|
||||
);
|
||||
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>);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -89,39 +126,16 @@ export default function ProductDetail() {
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<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 className="text-right">
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isInCart}
|
||||
size="lg"
|
||||
className="shadow-sm"
|
||||
>
|
||||
{isInCart ? 'Already in Cart' : 'Add to Cart'}
|
||||
</Button>
|
||||
<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>
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user