Changes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
130
src/pages/Events.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
463
src/pages/admin/AdminEvents.tsx
Normal file
463
src/pages/admin/AdminEvents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user