This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 01:54:13 +00:00
parent 278f709201
commit ff877266b0
13 changed files with 2540 additions and 231 deletions

View File

@@ -3,11 +3,13 @@ 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 { Card, CardContent } 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 { formatDuration } from '@/lib/format';
import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
interface Product {
id: string;
@@ -27,6 +29,7 @@ interface Lesson {
title: string;
content: string | null;
video_url: string | null;
duration_seconds: number | null;
position: number;
release_at: string | null;
}
@@ -40,14 +43,14 @@ 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);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
@@ -58,7 +61,6 @@ export default function Bootcamp() {
}, [user, authLoading, slug]);
const checkAccessAndFetch = async () => {
// First get the product
const { data: productData, error: productError } = await supabase
.from('products')
.select('id, title, slug')
@@ -67,14 +69,13 @@ export default function Bootcamp() {
.maybeSingle();
if (productError || !productData) {
toast({ title: 'Error', description: 'Bootcamp not found', variant: 'destructive' });
toast({ title: 'Error', description: 'Bootcamp tidak ditemukan', variant: 'destructive' });
navigate('/dashboard');
return;
}
setProduct(productData);
// Check access
const { data: accessData } = await supabase
.from('user_access')
.select('id')
@@ -83,14 +84,11 @@ export default function Bootcamp() {
.maybeSingle();
if (!accessData) {
toast({ title: 'Access denied', description: 'You don\'t have access to this bootcamp', variant: 'destructive' });
toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke bootcamp ini', variant: 'destructive' });
navigate('/dashboard');
return;
}
setHasAccess(true);
// Fetch modules with lessons
const { data: modulesData } = await supabase
.from('bootcamp_modules')
.select(`
@@ -102,6 +100,7 @@ export default function Bootcamp() {
title,
content,
video_url,
duration_seconds,
position,
release_at
)
@@ -116,13 +115,11 @@ export default function Bootcamp() {
}));
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')
@@ -148,23 +145,20 @@ export default function Bootcamp() {
if (error) {
if (error.code === '23505') {
toast({ title: 'Already completed', description: 'This lesson is already marked as completed' });
toast({ title: 'Info', description: 'Pelajaran sudah ditandai selesai' });
} else {
toast({ title: 'Error', description: 'Failed to mark as completed', variant: 'destructive' });
toast({ title: 'Error', description: 'Gagal menandai selesai', 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
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
goToNextLesson();
};
const goToNextLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex < allLessons.length - 1) {
@@ -174,7 +168,6 @@ export default function Bootcamp() {
const goToPrevLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex > 0) {
@@ -183,27 +176,70 @@ export default function Bootcamp() {
};
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
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]}`;
}
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
return url;
};
const completedCount = progress.length;
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const renderSidebarContent = () => (
<div className="p-4">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
{module.title}
</h3>
<div className="space-y-1">
{module.lessons.map((lesson) => {
const isCompleted = isLessonCompleted(lesson.id);
const isSelected = selectedLesson?.id === lesson.id;
const isReleased = !lesson.release_at || new Date(lesson.release_at) <= new Date();
return (
<button
key={lesson.id}
onClick={() => {
if (isReleased) {
setSelectedLesson(lesson);
setMobileMenuOpen(false);
}
}}
disabled={!isReleased}
className={cn(
"w-full text-left px-3 py-2 rounded-none text-sm flex items-center gap-2 transition-colors",
isSelected ? "bg-primary text-primary-foreground" : "hover:bg-muted",
!isReleased && "opacity-50 cursor-not-allowed"
)}
>
{isCompleted ? (
<Check className="w-4 h-4 shrink-0 text-accent" />
) : lesson.video_url ? (
<Play className="w-4 h-4 shrink-0" />
) : (
<BookOpen className="w-4 h-4 shrink-0" />
)}
<span className="truncate flex-1">{lesson.title}</span>
{lesson.duration_seconds && (
<span className="text-xs text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
)}
</button>
);
})}
</div>
</div>
))}
</div>
);
if (authLoading || loading) {
return (
<div className="min-h-screen bg-background">
<div className="flex">
<div className="w-80 border-r border-border p-4">
<div className="w-80 border-r border-border p-4 hidden md:block">
<Skeleton className="h-8 w-full mb-4" />
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full mb-2" />
@@ -221,94 +257,77 @@ export default function Bootcamp() {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-card">
<header className="border-b-2 border-border bg-card sticky top-0 z-50">
<div className="flex items-center justify-between px-4 h-16">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
<ChevronLeft className="w-4 h-4 mr-1" />
Back to Dashboard
<span className="hidden sm:inline">Kembali ke Dashboard</span>
</Button>
<h1 className="text-xl font-bold">{product?.title}</h1>
<h1 className="text-lg md:text-xl font-bold truncate">{product?.title}</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{completedCount} / {totalLessons} completed
<span className="text-sm text-muted-foreground hidden sm:inline">
{completedCount} / {totalLessons} selesai
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
<div className="w-24 md:w-32 h-2 bg-muted rounded-none overflow-hidden border border-border">
<div
className="h-full bg-primary transition-all"
style={{ width: `${totalLessons > 0 ? (completedCount / totalLessons) * 100 : 0}%` }}
/>
</div>
{/* Mobile menu trigger */}
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="sm" className="md:hidden">
<Menu className="w-5 h-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80 p-0 border-r-2 border-border">
<div className="p-4 border-b-2 border-border font-bold">Kurikulum</div>
<div className="overflow-y-auto h-[calc(100vh-60px)]">
{renderSidebarContent()}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
<div className="flex">
{/* Sidebar */}
{/* Desktop Sidebar */}
<aside className={cn(
"border-r border-border bg-card transition-all overflow-y-auto h-[calc(100vh-64px)]",
"hidden md:block border-r-2 border-border bg-card transition-all overflow-y-auto h-[calc(100vh-64px)] sticky top-16",
sidebarOpen ? "w-80" : "w-0"
)}>
{sidebarOpen && (
<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>
)}
{sidebarOpen && renderSidebarContent()}
</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"
className="hidden md:flex absolute top-1/2 -translate-y-1/2 bg-card border-2 border-border rounded-r-none p-1 z-10"
style={{ left: sidebarOpen ? '320px' : '0' }}
>
{sidebarOpen ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
{/* Main content */}
<main className="flex-1 p-8 h-[calc(100vh-64px)] overflow-y-auto">
<main className="flex-1 p-4 md:p-8 min-h-[calc(100vh-64px)] overflow-y-auto">
{selectedLesson ? (
<div className="max-w-4xl mx-auto">
<h2 className="text-3xl font-bold mb-6">{selectedLesson.title}</h2>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-2xl md:text-3xl font-bold">{selectedLesson.title}</h2>
{selectedLesson.duration_seconds && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
{formatDuration(selectedLesson.duration_seconds)}
</span>
)}
</div>
{selectedLesson.video_url && (
<div className="aspect-video bg-muted rounded-lg overflow-hidden mb-6">
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
<iframe
src={getVideoEmbed(selectedLesson.video_url)}
className="w-full h-full"
@@ -321,7 +340,7 @@ export default function Bootcamp() {
{selectedLesson.content && (
<Card className="border-2 border-border mb-6">
<CardContent className="pt-6">
<div
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: selectedLesson.content }}
/>
@@ -329,7 +348,7 @@ export default function Bootcamp() {
</Card>
)}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4 flex-wrap">
<Button
variant="outline"
onClick={goToPrevLesson}
@@ -337,7 +356,7 @@ export default function Bootcamp() {
className="border-2"
>
<ChevronLeft className="w-4 h-4 mr-2" />
Previous
Sebelumnya
</Button>
<Button
@@ -348,10 +367,10 @@ export default function Bootcamp() {
{isLessonCompleted(selectedLesson.id) ? (
<>
<Check className="w-4 h-4 mr-2" />
Completed
Sudah Selesai
</>
) : (
'Mark as Completed'
'Tandai Selesai'
)}
</Button>
@@ -361,7 +380,7 @@ export default function Bootcamp() {
disabled={modules.flatMap(m => m.lessons).findIndex(l => l.id === selectedLesson.id) === modules.flatMap(m => m.lessons).length - 1}
className="border-2"
>
Next
Selanjutnya
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
@@ -372,7 +391,7 @@ export default function Bootcamp() {
<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'}
{modules.length === 0 ? 'Belum ada pelajaran tersedia' : 'Pilih pelajaran untuk memulai'}
</p>
</CardContent>
</Card>

View File

@@ -1,13 +1,17 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { AppLayout } from '@/components/AppLayout';
import { useCart } from '@/contexts/CartContext';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from '@/hooks/use-toast';
import { Trash2 } from 'lucide-react';
import { formatIDR } from '@/lib/format';
import { Trash2, CreditCard, Loader2 } from 'lucide-react';
// Pakasir configuration - replace with your actual project slug
const PAKASIR_PROJECT_SLUG = 'learnhub'; // TODO: Replace with actual Pakasir project slug
export default function Checkout() {
const { items, removeItem, clearCart, total } = useCart();
@@ -17,84 +21,94 @@ export default function Checkout() {
const handleCheckout = async () => {
if (!user) {
toast({ title: 'Login required', description: 'Please login to complete your purchase' });
toast({ title: 'Login diperlukan', description: 'Silakan login untuk melanjutkan pembayaran' });
navigate('/auth');
return;
}
if (items.length === 0) {
toast({ title: 'Cart is empty', description: 'Add some products to your cart first', variant: 'destructive' });
toast({ title: 'Keranjang kosong', description: 'Tambahkan produk ke keranjang terlebih dahulu', variant: 'destructive' });
return;
}
setLoading(true);
// Create order
const { data: order, error: orderError } = await supabase
.from('orders')
.insert({
user_id: user.id,
total_amount: total,
status: 'pending'
})
.select()
.single();
try {
// Generate a unique order reference
const orderRef = `ORD${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
if (orderError || !order) {
toast({ title: 'Error', description: 'Failed to create order', variant: 'destructive' });
// Create order with pending payment status
const { data: order, error: orderError } = await supabase
.from('orders')
.insert({
user_id: user.id,
total_amount: total,
status: 'pending',
payment_provider: 'pakasir',
payment_reference: orderRef,
payment_status: 'pending'
})
.select()
.single();
if (orderError || !order) {
throw new Error('Gagal membuat order');
}
// Create order items
const orderItems = items.map(item => ({
order_id: order.id,
product_id: item.id,
unit_price: item.sale_price ?? item.price,
quantity: 1
}));
const { error: itemsError } = await supabase
.from('order_items')
.insert(orderItems);
if (itemsError) {
throw new Error('Gagal menambahkan item order');
}
// Build Pakasir payment URL
const amountInRupiah = Math.round(total);
const pakasirUrl = `https://app.pakasir.com/pay/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`;
// Clear cart and redirect to Pakasir
clearCart();
toast({
title: 'Mengarahkan ke pembayaran...',
description: 'Anda akan diarahkan ke halaman pembayaran Pakasir'
});
// Redirect to Pakasir payment page
window.location.href = pakasirUrl;
} catch (error) {
console.error('Checkout error:', error);
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Terjadi kesalahan saat checkout',
variant: 'destructive'
});
} finally {
setLoading(false);
return;
}
// Create order items
const orderItems = items.map(item => ({
order_id: order.id,
product_id: item.id,
unit_price: item.sale_price ?? item.price,
quantity: 1
}));
const { error: itemsError } = await supabase
.from('order_items')
.insert(orderItems);
if (itemsError) {
toast({ title: 'Error', description: 'Failed to add order items', variant: 'destructive' });
setLoading(false);
return;
}
// For demo: mark as paid and grant access
await supabase
.from('orders')
.update({ status: 'paid' })
.eq('id', order.id);
// Grant access to products
const accessRecords = items.map(item => ({
user_id: user.id,
product_id: item.id
}));
await supabase.from('user_access').insert(accessRecords);
clearCart();
toast({ title: 'Success', description: 'Your order has been placed!' });
navigate('/dashboard');
setLoading(false);
};
return (
<Layout>
<AppLayout>
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Checkout</h1>
{items.length === 0 ? (
<Card className="border-2 border-border">
<CardContent className="py-12 text-center">
<p className="text-muted-foreground mb-4">Your cart is empty</p>
<p className="text-muted-foreground mb-4">Keranjang belanja Anda kosong</p>
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
Browse Products
Lihat Produk
</Button>
</CardContent>
</Card>
@@ -111,11 +125,11 @@ export default function Checkout() {
</div>
<div className="flex items-center gap-4">
<span className="font-bold">
${item.sale_price ?? item.price}
{formatIDR(item.sale_price ?? item.price)}
</span>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(item.id)}
>
<Trash2 className="w-4 h-4" />
@@ -130,22 +144,34 @@ export default function Checkout() {
<div>
<Card className="border-2 border-border shadow-md sticky top-4">
<CardHeader>
<CardTitle>Order Summary</CardTitle>
<CardTitle>Ringkasan Order</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between text-lg">
<span>Total</span>
<span className="font-bold">${total}</span>
<span className="font-bold">{formatIDR(total)}</span>
</div>
<Button
onClick={handleCheckout}
className="w-full shadow-sm"
<Button
onClick={handleCheckout}
className="w-full shadow-sm"
disabled={loading}
>
{loading ? 'Processing...' : user ? 'Complete Purchase' : 'Login to Checkout'}
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Memproses...
</>
) : user ? (
<>
<CreditCard className="w-4 h-4 mr-2" />
Bayar Sekarang
</>
) : (
'Login untuk Checkout'
)}
</Button>
<p className="text-xs text-muted-foreground text-center">
This is a demo checkout. No actual payment will be processed.
Pembayaran diproses melalui Pakasir (QRIS, Transfer Bank)
</p>
</CardContent>
</Card>
@@ -153,6 +179,6 @@ export default function Checkout() {
</div>
)}
</div>
</Layout>
</AppLayout>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { AppLayout } from '@/components/AppLayout';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -8,10 +8,31 @@ 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 { formatIDR, formatDate } from '@/lib/format';
import { Video, Calendar, BookOpen, ArrowRight } from 'lucide-react';
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; }
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;
payment_status: string | null;
created_at: string;
}
export default function Dashboard() {
const { user, loading: authLoading } = useAuth();
@@ -36,39 +57,158 @@ export default function Dashboard() {
};
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';
default: return 'bg-secondary';
}
};
const getPaymentStatusLabel = (status: string | null) => {
switch (status) {
case 'paid': return 'Lunas';
case 'pending': return 'Menunggu Pembayaran';
case 'failed': return 'Gagal';
default: return status || 'Pending';
}
};
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;
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" />
Jadwalkan Konsultasi
</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" />
Gabung Webinar
</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" />
Tonton Rekaman
</a>
</Button>
)}
</div>
);
case 'bootcamp':
return (
<Button onClick={() => navigate(`/bootcamp/${item.product.slug}`)} className="shadow-sm">
<BookOpen className="w-4 h-4 mr-2" />
Lanjutkan 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 (
<AppLayout>
<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>
</AppLayout>
);
}
return (
<Layout>
<AppLayout>
<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>
<p className="text-muted-foreground mb-8">Kelola akses dan riwayat pembelian Anda</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">Akses Saya</TabsTrigger>
<TabsTrigger value="orders">Riwayat Order</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>{renderAccessActions(item)}</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">Anda belum memiliki akses ke produk apapun</p>
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
Lihat Produk
</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">Aktif</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4 line-clamp-2">{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">Belum ada order</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">{formatDate(order.created_at)}</p>
</div>
<div className="flex items-center gap-4">
<Badge className={getStatusColor(order.payment_status || order.status)}>
{getPaymentStatusLabel(order.payment_status || order.status)}
</Badge>
<span className="font-bold">{formatIDR(order.total_amount)}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</Layout>
</AppLayout>
);
}
}

130
src/pages/Events.tsx Normal file
View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { formatDateTime } from '@/lib/format';
import { Calendar, Video, Users, BookOpen } from 'lucide-react';
interface Event {
id: string;
type: string;
product_id: string | null;
title: string;
starts_at: string;
ends_at: string;
status: string;
product?: {
slug: string;
title: string;
} | null;
}
export default function Events() {
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
const { data, error } = await supabase
.from('events')
.select(`
*,
product:products (slug, title)
`)
.eq('status', 'confirmed')
.gte('ends_at', new Date().toISOString())
.order('starts_at', { ascending: true });
if (!error && data) {
setEvents(data as unknown as Event[]);
}
setLoading(false);
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'bootcamp': return BookOpen;
case 'webinar': return Video;
case 'consulting': return Users;
default: return Calendar;
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'bootcamp': return 'Bootcamp';
case 'webinar': return 'Webinar';
case 'consulting': return 'Konsultasi';
default: return type;
}
};
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<div className="flex items-center gap-3 mb-8">
<Calendar className="w-8 h-8" />
<div>
<h1 className="text-4xl font-bold">Kalender Acara</h1>
<p className="text-muted-foreground">Jadwal webinar, bootcamp, dan sesi konsultasi</p>
</div>
</div>
{loading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : events.length === 0 ? (
<Card className="border-2 border-border">
<CardContent className="py-12 text-center">
<Calendar className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">Belum ada acara terjadwal</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{events.map((event) => {
const Icon = getTypeIcon(event.type);
return (
<Card key={event.id} className="border-2 border-border">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-muted rounded-none">
<Icon className="w-5 h-5" />
</div>
<div>
<CardTitle className="text-lg">{event.title}</CardTitle>
<CardDescription>{formatDateTime(event.starts_at)}</CardDescription>
</div>
</div>
<Badge className="bg-secondary">{getTypeLabel(event.type)}</Badge>
</div>
</CardHeader>
<CardContent>
{event.product && (
<Link to={`/products/${event.product.slug}`}>
<Button variant="outline" size="sm" className="border-2">
Lihat Produk
</Button>
</Link>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
</AppLayout>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { Layout } from '@/components/Layout';
import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -9,7 +9,9 @@ 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';
import { formatIDR, formatDuration } from '@/lib/format';
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
interface Product {
id: string;
@@ -25,13 +27,29 @@ interface Product {
created_at: string;
}
interface Module {
id: string;
title: string;
position: number;
lessons: Lesson[];
}
interface Lesson {
id: string;
title: string;
duration_seconds: number | null;
position: number;
}
export default function ProductDetail() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const [product, setProduct] = useState<Product | null>(null);
const [modules, setModules] = useState<Module[]>([]);
const [loading, setLoading] = useState(true);
const [hasAccess, setHasAccess] = useState(false);
const [checkingAccess, setCheckingAccess] = useState(true);
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
const { addItem, items } = useCart();
const { user } = useAuth();
@@ -47,6 +65,12 @@ export default function ProductDetail() {
}
}, [product, user]);
useEffect(() => {
if (product?.type === 'bootcamp') {
fetchCurriculum();
}
}, [product]);
const fetchProduct = async () => {
const { data, error } = await supabase
.from('products')
@@ -56,13 +80,45 @@ export default function ProductDetail() {
.maybeSingle();
if (error || !data) {
toast({ title: 'Error', description: 'Product not found', variant: 'destructive' });
toast({ title: 'Error', description: 'Produk tidak ditemukan', variant: 'destructive' });
} else {
setProduct(data);
}
setLoading(false);
};
const fetchCurriculum = async () => {
if (!product) return;
const { data: modulesData } = await supabase
.from('bootcamp_modules')
.select(`
id,
title,
position,
bootcamp_lessons (
id,
title,
duration_seconds,
position
)
`)
.eq('product_id', product.id)
.order('position');
if (modulesData) {
const sorted = modulesData.map(m => ({
...m,
lessons: (m.bootcamp_lessons as Lesson[]).sort((a, b) => a.position - b.position)
}));
setModules(sorted);
// Expand first module by default
if (sorted.length > 0) {
setExpandedModules(new Set([sorted[0].id]));
}
}
};
const checkUserAccess = async () => {
if (!product || !user) return;
const { data } = await supabase
@@ -78,7 +134,7 @@ export default function ProductDetail() {
const handleAddToCart = () => {
if (!product) return;
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` });
toast({ title: 'Ditambahkan', description: `${product.title} sudah ditambahkan ke keranjang` });
};
const isInCart = product ? items.some(item => item.id === product.id) : false;
@@ -91,53 +147,200 @@ export default function ProductDetail() {
return url;
};
const totalDuration = modules.reduce((total, m) =>
total + m.lessons.reduce((sum, l) => sum + (l.duration_seconds || 0), 0), 0
);
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const toggleModule = (id: string) => {
const newSet = new Set(expandedModules);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
setExpandedModules(newSet);
};
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 (<AppLayout><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></AppLayout>);
}
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 (<AppLayout><div className="container mx-auto px-4 py-8 text-center"><h1 className="text-2xl font-bold">Produk tidak ditemukan</h1></div></AppLayout>);
}
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>);
return (
<Button onClick={handleAddToCart} disabled={isInCart} size="lg" className="shadow-sm">
{isInCart ? 'Sudah di Keranjang' : 'Tambah ke Keranjang'}
</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>);
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" />
Jadwalkan Konsultasi
</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 (
<div className="space-y-4">
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
<iframe
src={getVideoEmbed(product.recording_url)}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
<Button asChild variant="outline" className="border-2">
<a href={product.recording_url} target="_blank" rel="noopener noreferrer">
<Video className="w-4 h-4 mr-2" />
Tonton Rekaman
</a>
</Button>
</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>;
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" />
Gabung Webinar
</a>
</Button>
) : <Badge className="bg-secondary">Rekaman segera tersedia</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>);
return (
<Button onClick={() => navigate(`/bootcamp/${product.slug}`)} size="lg" className="shadow-sm">
<BookOpen className="w-4 h-4 mr-2" />
Mulai Bootcamp
</Button>
);
default:
return null;
}
};
return (
<Layout>
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<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 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>)}
const renderCurriculumPreview = () => {
if (product.type !== 'bootcamp' || modules.length === 0) return null;
return (
<Card className="border-2 border-border mb-6">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold">Kurikulum</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="w-4 h-4" />
{totalLessons} Pelajaran
</span>
{totalDuration > 0 && (
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatDuration(totalDuration)}
</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>
{renderActionButtons()}
<div className="space-y-2">
{modules.map((module) => (
<Collapsible
key={module.id}
open={expandedModules.has(module.id)}
onOpenChange={() => toggleModule(module.id)}
>
<CollapsibleTrigger className="flex items-center justify-between w-full p-3 bg-muted hover:bg-accent transition-colors text-left">
<span className="font-medium">{module.title}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{module.lessons.length} pelajaran</span>
{expandedModules.has(module.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-2">
{module.lessons.map((lesson) => (
<div key={lesson.id} className="flex items-center justify-between py-1 text-sm">
<div className="flex items-center gap-2">
<Play className="w-3 h-3 text-muted-foreground" />
<span>{lesson.title}</span>
</div>
{lesson.duration_seconds && (
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
)}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
</CardContent>
</Card>
);
};
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
<div>
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
<div className="flex items-center gap-2">
<Badge className="bg-secondary capitalize">{product.type}</Badge>
{hasAccess && <Badge className="bg-accent">Anda memiliki akses</Badge>}
</div>
</div>
<div className="text-right">
{product.sale_price ? (
<div>
<span className="text-3xl font-bold">{formatIDR(product.sale_price)}</span>
<span className="text-muted-foreground line-through ml-2">{formatIDR(product.price)}</span>
</div>
) : (
<span className="text-3xl font-bold">{formatIDR(product.price)}</span>
)}
{product.type === 'bootcamp' && totalDuration > 0 && (
<p className="text-sm text-muted-foreground mt-1">
Total: {formatDuration(totalDuration)} waktu belajar
</p>
)}
</div>
</div>
{product.description && (
<div
className="prose max-w-none mb-6 text-muted-foreground"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
)}
{renderCurriculumPreview()}
{product.content && (
<Card className="border-2 border-border mb-6">
<CardContent className="pt-6">
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: product.content }}
/>
</CardContent>
</Card>
)}
<div className="flex gap-4">
{renderActionButtons()}
</div>
</div>
</div>
</Layout>
</AppLayout>
);
}
}

View File

@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { Layout } from '@/components/Layout';
import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useCart } from '@/contexts/CartContext';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { formatIDR } from '@/lib/format';
interface Product {
id: string;
@@ -37,7 +38,7 @@ export default function Products() {
.order('created_at', { ascending: false });
if (error) {
toast({ title: 'Error', description: 'Failed to load products', variant: 'destructive' });
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
} else {
setProducts(data || []);
}
@@ -52,25 +53,25 @@ export default function Products() {
sale_price: product.sale_price,
type: product.type,
});
toast({ title: 'Added to cart', description: `${product.title} has been added to your cart` });
toast({ title: 'Ditambahkan', description: `${product.title} sudah ditambahkan ke keranjang` });
};
const isInCart = (productId: string) => items.some(item => item.id === productId);
const getTypeColor = (type: string) => {
const getTypeLabel = (type: string) => {
switch (type) {
case 'consulting': return 'bg-secondary';
case 'webinar': return 'bg-accent';
case 'bootcamp': return 'bg-muted';
default: return 'bg-secondary';
case 'consulting': return 'Konsultasi';
case 'webinar': return 'Webinar';
case 'bootcamp': return 'Bootcamp';
default: return type;
}
};
return (
<Layout>
<AppLayout>
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-2">Products</h1>
<p className="text-muted-foreground mb-8">Browse our consulting, webinars, and bootcamps</p>
<h1 className="text-4xl font-bold mb-2">Produk</h1>
<p className="text-muted-foreground mb-8">Jelajahi konsultasi, webinar, dan bootcamp kami</p>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -88,7 +89,7 @@ export default function Products() {
</div>
) : products.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No products available yet.</p>
<p className="text-muted-foreground">Belum ada produk tersedia.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -97,7 +98,7 @@ export default function Products() {
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl">{product.title}</CardTitle>
<Badge className={getTypeColor(product.type)}>{product.type}</Badge>
<Badge className="bg-secondary">{getTypeLabel(product.type)}</Badge>
</div>
<CardDescription className="line-clamp-2">{product.description}</CardDescription>
</CardHeader>
@@ -105,23 +106,23 @@ export default function Products() {
<div className="flex items-center gap-2 mb-4">
{product.sale_price ? (
<>
<span className="text-2xl font-bold">${product.sale_price}</span>
<span className="text-muted-foreground line-through">${product.price}</span>
<span className="text-2xl font-bold">{formatIDR(product.sale_price)}</span>
<span className="text-muted-foreground line-through">{formatIDR(product.price)}</span>
</>
) : (
<span className="text-2xl font-bold">${product.price}</span>
<span className="text-2xl font-bold">{formatIDR(product.price)}</span>
)}
</div>
<div className="flex gap-2">
<Link to={`/products/${product.slug}`} className="flex-1">
<Button variant="outline" className="w-full border-2">View Details</Button>
<Button variant="outline" className="w-full border-2">Lihat Detail</Button>
</Link>
<Button
onClick={() => handleAddToCart(product)}
<Button
onClick={() => handleAddToCart(product)}
disabled={isInCart(product.id)}
className="shadow-xs"
>
{isInCart(product.id) ? 'In Cart' : 'Add'}
{isInCart(product.id) ? 'Di Keranjang' : 'Tambah'}
</Button>
</div>
</CardContent>
@@ -130,6 +131,6 @@ export default function Products() {
</div>
)}
</div>
</Layout>
</AppLayout>
);
}

View File

@@ -0,0 +1,463 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { AppLayout } from '@/components/AppLayout';
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, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { Plus, Pencil, Trash2, Calendar, Clock } from 'lucide-react';
import { formatDateTime } from '@/lib/format';
interface Event {
id: string;
type: string;
product_id: string | null;
title: string;
starts_at: string;
ends_at: string;
status: string;
}
interface AvailabilityBlock {
id: string;
kind: string;
starts_at: string;
ends_at: string;
note: string | null;
}
interface Product {
id: string;
title: string;
}
const emptyEvent = {
type: 'webinar',
product_id: '' as string,
title: '',
starts_at: '',
ends_at: '',
status: 'confirmed',
};
const emptyBlock = {
kind: 'blocked',
starts_at: '',
ends_at: '',
note: '',
};
export default function AdminEvents() {
const { user, isAdmin, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [events, setEvents] = useState<Event[]>([]);
const [blocks, setBlocks] = useState<AvailabilityBlock[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [eventDialogOpen, setEventDialogOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [eventForm, setEventForm] = useState(emptyEvent);
const [blockDialogOpen, setBlockDialogOpen] = useState(false);
const [editingBlock, setEditingBlock] = useState<AvailabilityBlock | null>(null);
const [blockForm, setBlockForm] = useState(emptyBlock);
useEffect(() => {
if (!authLoading) {
if (!user) navigate('/auth');
else if (!isAdmin) navigate('/dashboard');
else fetchData();
}
}, [user, isAdmin, authLoading]);
const fetchData = async () => {
const [eventsRes, blocksRes, productsRes] = await Promise.all([
supabase.from('events').select('*').order('starts_at', { ascending: false }),
supabase.from('availability_blocks').select('*').order('starts_at', { ascending: false }),
supabase.from('products').select('id, title').eq('is_active', true),
]);
if (eventsRes.data) setEvents(eventsRes.data);
if (blocksRes.data) setBlocks(blocksRes.data);
if (productsRes.data) setProducts(productsRes.data);
setLoading(false);
};
// Event handlers
const handleNewEvent = () => {
setEditingEvent(null);
setEventForm(emptyEvent);
setEventDialogOpen(true);
};
const handleEditEvent = (event: Event) => {
setEditingEvent(event);
setEventForm({
type: event.type,
product_id: event.product_id || '',
title: event.title,
starts_at: event.starts_at.slice(0, 16),
ends_at: event.ends_at.slice(0, 16),
status: event.status,
});
setEventDialogOpen(true);
};
const handleSaveEvent = async () => {
if (!eventForm.title || !eventForm.starts_at || !eventForm.ends_at) {
toast({ title: 'Error', description: 'Lengkapi semua field yang wajib diisi', variant: 'destructive' });
return;
}
const eventData = {
type: eventForm.type,
product_id: eventForm.product_id || null,
title: eventForm.title,
starts_at: new Date(eventForm.starts_at).toISOString(),
ends_at: new Date(eventForm.ends_at).toISOString(),
status: eventForm.status,
};
if (editingEvent) {
const { error } = await supabase.from('events').update(eventData).eq('id', editingEvent.id);
if (error) toast({ title: 'Error', description: 'Gagal mengupdate event', variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Event diupdate' }); setEventDialogOpen(false); fetchData(); }
} else {
const { error } = await supabase.from('events').insert(eventData);
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Event dibuat' }); setEventDialogOpen(false); fetchData(); }
}
};
const handleDeleteEvent = async (id: string) => {
if (!confirm('Hapus event ini?')) return;
const { error } = await supabase.from('events').delete().eq('id', id);
if (error) toast({ title: 'Error', description: 'Gagal menghapus event', variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Event dihapus' }); fetchData(); }
};
// Block handlers
const handleNewBlock = () => {
setEditingBlock(null);
setBlockForm(emptyBlock);
setBlockDialogOpen(true);
};
const handleEditBlock = (block: AvailabilityBlock) => {
setEditingBlock(block);
setBlockForm({
kind: block.kind,
starts_at: block.starts_at.slice(0, 16),
ends_at: block.ends_at.slice(0, 16),
note: block.note || '',
});
setBlockDialogOpen(true);
};
const handleSaveBlock = async () => {
if (!blockForm.starts_at || !blockForm.ends_at) {
toast({ title: 'Error', description: 'Lengkapi waktu mulai dan selesai', variant: 'destructive' });
return;
}
const blockData = {
kind: blockForm.kind,
starts_at: new Date(blockForm.starts_at).toISOString(),
ends_at: new Date(blockForm.ends_at).toISOString(),
note: blockForm.note || null,
};
if (editingBlock) {
const { error } = await supabase.from('availability_blocks').update(blockData).eq('id', editingBlock.id);
if (error) toast({ title: 'Error', description: 'Gagal mengupdate', variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Blok diupdate' }); setBlockDialogOpen(false); fetchData(); }
} else {
const { error } = await supabase.from('availability_blocks').insert(blockData);
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Blok dibuat' }); setBlockDialogOpen(false); fetchData(); }
}
};
const handleDeleteBlock = async (id: string) => {
if (!confirm('Hapus blok waktu ini?')) return;
const { error } = await supabase.from('availability_blocks').delete().eq('id', id);
if (error) toast({ title: 'Error', description: 'Gagal menghapus', variant: 'destructive' });
else { toast({ title: 'Berhasil', description: 'Blok dihapus' }); fetchData(); }
};
if (authLoading || loading) {
return (
<AppLayout>
<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>
</AppLayout>
);
}
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<div className="flex items-center gap-3 mb-8">
<Calendar className="w-8 h-8" />
<div>
<h1 className="text-4xl font-bold">Kalender & Jadwal</h1>
<p className="text-muted-foreground">Kelola event dan blok ketersediaan</p>
</div>
</div>
<Tabs defaultValue="events" className="space-y-6">
<TabsList className="border-2 border-border">
<TabsTrigger value="events">Event</TabsTrigger>
<TabsTrigger value="availability">Ketersediaan</TabsTrigger>
</TabsList>
<TabsContent value="events">
<Card className="border-2 border-border">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Daftar Event</CardTitle>
<Button onClick={handleNewEvent} className="shadow-sm">
<Plus className="w-4 h-4 mr-2" />
Tambah Event
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Judul</TableHead>
<TableHead>Tipe</TableHead>
<TableHead>Mulai</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => (
<TableRow key={event.id}>
<TableCell className="font-medium">{event.title}</TableCell>
<TableCell className="capitalize">{event.type}</TableCell>
<TableCell>{formatDateTime(event.starts_at)}</TableCell>
<TableCell>
<Badge className={event.status === 'confirmed' ? 'bg-accent' : 'bg-muted'}>
{event.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEditEvent(event)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteEvent(event.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{events.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Belum ada event
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="availability">
<Card className="border-2 border-border">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Blok Ketersediaan</CardTitle>
<Button onClick={handleNewBlock} className="shadow-sm">
<Plus className="w-4 h-4 mr-2" />
Tambah Blok
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tipe</TableHead>
<TableHead>Mulai</TableHead>
<TableHead>Selesai</TableHead>
<TableHead>Catatan</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{blocks.map((block) => (
<TableRow key={block.id}>
<TableCell>
<Badge className={block.kind === 'available' ? 'bg-accent' : 'bg-destructive'}>
{block.kind === 'available' ? 'Tersedia' : 'Tidak Tersedia'}
</Badge>
</TableCell>
<TableCell>{formatDateTime(block.starts_at)}</TableCell>
<TableCell>{formatDateTime(block.ends_at)}</TableCell>
<TableCell className="text-muted-foreground">{block.note || '-'}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEditBlock(block)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteBlock(block.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{blocks.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Belum ada blok ketersediaan
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Event Dialog */}
<Dialog open={eventDialogOpen} onOpenChange={setEventDialogOpen}>
<DialogContent className="max-w-md border-2 border-border">
<DialogHeader>
<DialogTitle>{editingEvent ? 'Edit Event' : 'Buat Event Baru'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Judul *</Label>
<Input
value={eventForm.title}
onChange={(e) => setEventForm({ ...eventForm, title: e.target.value })}
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Tipe</Label>
<Select value={eventForm.type} onValueChange={(v) => setEventForm({ ...eventForm, type: v })}>
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="webinar">Webinar</SelectItem>
<SelectItem value="bootcamp">Bootcamp</SelectItem>
<SelectItem value="consulting">Konsultasi</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Produk Terkait</Label>
<Select value={eventForm.product_id} onValueChange={(v) => setEventForm({ ...eventForm, product_id: v })}>
<SelectTrigger className="border-2"><SelectValue placeholder="Pilih produk (opsional)" /></SelectTrigger>
<SelectContent>
{products.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.title}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Mulai *</Label>
<Input
type="datetime-local"
value={eventForm.starts_at}
onChange={(e) => setEventForm({ ...eventForm, starts_at: e.target.value })}
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Selesai *</Label>
<Input
type="datetime-local"
value={eventForm.ends_at}
onChange={(e) => setEventForm({ ...eventForm, ends_at: e.target.value })}
className="border-2"
/>
</div>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={eventForm.status} onValueChange={(v) => setEventForm({ ...eventForm, status: v })}>
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="confirmed">Confirmed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleSaveEvent} className="w-full shadow-sm">Simpan</Button>
</div>
</DialogContent>
</Dialog>
{/* Block Dialog */}
<Dialog open={blockDialogOpen} onOpenChange={setBlockDialogOpen}>
<DialogContent className="max-w-md border-2 border-border">
<DialogHeader>
<DialogTitle>{editingBlock ? 'Edit Blok' : 'Tambah Blok Ketersediaan'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Tipe</Label>
<Select value={blockForm.kind} onValueChange={(v) => setBlockForm({ ...blockForm, kind: v })}>
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="available">Tersedia</SelectItem>
<SelectItem value="blocked">Tidak Tersedia</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Mulai *</Label>
<Input
type="datetime-local"
value={blockForm.starts_at}
onChange={(e) => setBlockForm({ ...blockForm, starts_at: e.target.value })}
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Selesai *</Label>
<Input
type="datetime-local"
value={blockForm.ends_at}
onChange={(e) => setBlockForm({ ...blockForm, ends_at: e.target.value })}
className="border-2"
/>
</div>
</div>
<div className="space-y-2">
<Label>Catatan</Label>
<Textarea
value={blockForm.note}
onChange={(e) => setBlockForm({ ...blockForm, note: e.target.value })}
placeholder="Contoh: Libur nasional, sudah ada jadwal lain..."
className="border-2"
rows={2}
/>
</div>
<Button onClick={handleSaveBlock} className="w-full shadow-sm">Simpan</Button>
</div>
</DialogContent>
</Dialog>
</div>
</AppLayout>
);
}