@@ -155,14 +316,14 @@ export default function Checkout() {
) : user ? (
<>
- Bayar Sekarang
+ Bayar dengan {paymentMethod === "qris" ? "QRIS" : "PayPal"}
>
) : (
"Login untuk Checkout"
)}
- Pembayaran diproses melalui Pakasir (QRIS, Transfer Bank)
+ Pembayaran diproses melalui Pakasir
diff --git a/src/pages/admin/AdminBootcamp.tsx b/src/pages/admin/AdminBootcamp.tsx
new file mode 100644
index 0000000..0dd6734
--- /dev/null
+++ b/src/pages/admin/AdminBootcamp.tsx
@@ -0,0 +1,91 @@
+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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
+import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
+import { BookOpen } from 'lucide-react';
+
+interface Product {
+ id: string;
+ title: string;
+ slug: string;
+}
+
+export default function AdminBootcamp() {
+ const { user, isAdmin, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [bootcamps, setBootcamps] = useState
([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!authLoading) {
+ if (!user) navigate('/auth');
+ else if (!isAdmin) navigate('/dashboard');
+ else fetchBootcamps();
+ }
+ }, [user, isAdmin, authLoading]);
+
+ const fetchBootcamps = async () => {
+ const { data, error } = await supabase
+ .from('products')
+ .select('id, title, slug')
+ .eq('type', 'bootcamp')
+ .order('created_at', { ascending: false });
+ if (!error && data) setBootcamps(data);
+ setLoading(false);
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Manajemen Bootcamp
+
Kelola kurikulum bootcamp
+
+
+
+ {bootcamps.length === 0 ? (
+
+
+ Belum ada bootcamp. Buat produk dengan tipe bootcamp terlebih dahulu.
+
+
+
+ ) : (
+
+ {bootcamps.map((bootcamp) => (
+
+
+ {bootcamp.title}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/admin/AdminDashboard.tsx b/src/pages/admin/AdminDashboard.tsx
new file mode 100644
index 0000000..cee2ad3
--- /dev/null
+++ b/src/pages/admin/AdminDashboard.tsx
@@ -0,0 +1,134 @@
+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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { formatIDR } from '@/lib/format';
+import { Package, Users, Receipt, TrendingUp, BookOpen, Calendar } from 'lucide-react';
+
+interface Stats {
+ totalProducts: number;
+ totalMembers: number;
+ totalOrders: number;
+ totalRevenue: number;
+ pendingOrders: number;
+ activeBootcamps: number;
+}
+
+export default function AdminDashboard() {
+ const { user, isAdmin, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [stats, setStats] = useState(null);
+ const [recentOrders, setRecentOrders] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!authLoading) {
+ if (!user) navigate('/auth');
+ else if (!isAdmin) navigate('/dashboard');
+ else fetchData();
+ }
+ }, [user, isAdmin, authLoading]);
+
+ const fetchData = async () => {
+ const [productsRes, profilesRes, ordersRes, paidOrdersRes, bootcampRes] = await Promise.all([
+ supabase.from('products').select('id', { count: 'exact' }),
+ supabase.from('profiles').select('id', { count: 'exact' }),
+ supabase.from('orders').select('*').order('created_at', { ascending: false }).limit(5),
+ supabase.from('orders').select('total_amount').eq('payment_status', 'paid'),
+ supabase.from('products').select('id', { count: 'exact' }).eq('type', 'bootcamp').eq('is_active', true),
+ ]);
+
+ const pendingRes = await supabase.from('orders').select('id', { count: 'exact' }).eq('payment_status', 'pending');
+
+ const totalRevenue = paidOrdersRes.data?.reduce((sum, o) => sum + (o.total_amount || 0), 0) || 0;
+
+ setStats({
+ totalProducts: productsRes.count || 0,
+ totalMembers: profilesRes.count || 0,
+ totalOrders: ordersRes.data?.length || 0,
+ totalRevenue,
+ pendingOrders: pendingRes.count || 0,
+ activeBootcamps: bootcampRes.count || 0,
+ });
+
+ setRecentOrders(ordersRes.data || []);
+ setLoading(false);
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+ {[...Array(4)].map((_, i) => )}
+
+
+
+ );
+ }
+
+ const statCards = [
+ { label: 'Total Produk', value: stats?.totalProducts || 0, icon: Package, color: 'text-primary' },
+ { label: 'Total Member', value: stats?.totalMembers || 0, icon: Users, color: 'text-accent' },
+ { label: 'Order Pending', value: stats?.pendingOrders || 0, icon: Receipt, color: 'text-secondary-foreground' },
+ { label: 'Total Pendapatan', value: formatIDR(stats?.totalRevenue || 0), icon: TrendingUp, color: 'text-primary' },
+ { label: 'Bootcamp Aktif', value: stats?.activeBootcamps || 0, icon: BookOpen, color: 'text-accent' },
+ ];
+
+ return (
+
+
+
Admin Dashboard
+
Ringkasan statistik platform
+
+
+ {statCards.map((stat) => (
+
+
+
+
+
+
{stat.value}
+
{stat.label}
+
+
+
+
+ ))}
+
+
+
+
+ Order Terbaru
+
+
+ {recentOrders.length === 0 ? (
+ Belum ada order
+ ) : (
+
+ {recentOrders.map((order) => (
+
+
+
{order.id.slice(0, 8)}
+
{new Date(order.created_at).toLocaleDateString('id-ID')}
+
+
+
{formatIDR(order.total_amount)}
+
+ {order.payment_status === 'paid' ? 'Lunas' : 'Pending'}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/AdminMembers.tsx b/src/pages/admin/AdminMembers.tsx
new file mode 100644
index 0000000..126de0d
--- /dev/null
+++ b/src/pages/admin/AdminMembers.tsx
@@ -0,0 +1,188 @@
+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 { Card, CardContent } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Skeleton } from '@/components/ui/skeleton';
+import { formatDateTime } from '@/lib/format';
+import { Eye, Shield, ShieldOff } from 'lucide-react';
+import { toast } from '@/hooks/use-toast';
+
+interface Member {
+ id: string;
+ email: string | null;
+ full_name: string | null;
+ created_at: string;
+ isAdmin?: boolean;
+}
+
+interface UserAccess {
+ id: string;
+ granted_at: string;
+ product: { title: string };
+}
+
+export default function AdminMembers() {
+ const { user, isAdmin, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [members, setMembers] = useState([]);
+ const [adminIds, setAdminIds] = useState>(new Set());
+ const [loading, setLoading] = useState(true);
+ const [selectedMember, setSelectedMember] = useState(null);
+ const [memberAccess, setMemberAccess] = useState([]);
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ useEffect(() => {
+ if (!authLoading) {
+ if (!user) navigate('/auth');
+ else if (!isAdmin) navigate('/dashboard');
+ else fetchMembers();
+ }
+ }, [user, isAdmin, authLoading]);
+
+ const fetchMembers = async () => {
+ const [profilesRes, rolesRes] = await Promise.all([
+ supabase.from('profiles').select('*').order('created_at', { ascending: false }),
+ supabase.from('user_roles').select('user_id').eq('role', 'admin'),
+ ]);
+
+ const admins = new Set((rolesRes.data || []).map(r => r.user_id));
+ setAdminIds(admins);
+
+ if (profilesRes.data) {
+ setMembers(profilesRes.data.map(p => ({ ...p, isAdmin: admins.has(p.id) })));
+ }
+ setLoading(false);
+ };
+
+ const viewMemberDetails = async (member: Member) => {
+ setSelectedMember(member);
+ const { data } = await supabase
+ .from('user_access')
+ .select('*, product:products(title)')
+ .eq('user_id', member.id);
+ setMemberAccess(data as unknown as UserAccess[] || []);
+ setDialogOpen(true);
+ };
+
+ const toggleAdminRole = async (memberId: string, currentlyAdmin: boolean) => {
+ if (currentlyAdmin) {
+ const { error } = await supabase.from('user_roles').delete().eq('user_id', memberId).eq('role', 'admin');
+ if (error) toast({ title: 'Error', description: 'Gagal menghapus role admin', variant: 'destructive' });
+ else { toast({ title: 'Berhasil', description: 'Role admin dihapus' }); fetchMembers(); }
+ } else {
+ const { error } = await supabase.from('user_roles').insert({ user_id: memberId, role: 'admin' });
+ if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
+ else { toast({ title: 'Berhasil', description: 'Role admin ditambahkan' }); fetchMembers(); }
+ }
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Manajemen Member
+
Kelola semua pengguna
+
+
+
+
+
+
+ Email
+ Nama
+ Role
+ Bergabung
+ Aksi
+
+
+
+ {members.map((member) => (
+
+ {member.email || '-'}
+ {member.full_name || '-'}
+
+ {adminIds.has(member.id) ? (
+ Admin
+ ) : (
+ Member
+ )}
+
+ {formatDateTime(member.created_at)}
+
+
+
+
+
+ ))}
+ {members.length === 0 && (
+
+
+ Belum ada member
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/AdminOrders.tsx b/src/pages/admin/AdminOrders.tsx
new file mode 100644
index 0000000..3718738
--- /dev/null
+++ b/src/pages/admin/AdminOrders.tsx
@@ -0,0 +1,211 @@
+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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Skeleton } from '@/components/ui/skeleton';
+import { formatIDR, formatDateTime } from '@/lib/format';
+import { Eye, CheckCircle, XCircle } from 'lucide-react';
+import { toast } from '@/hooks/use-toast';
+
+interface Order {
+ id: string;
+ user_id: string;
+ total_amount: number;
+ status: string;
+ payment_status: string | null;
+ payment_method: string | null;
+ payment_reference: string | null;
+ created_at: string;
+ profile?: { email: string } | null;
+}
+
+interface OrderItem {
+ id: string;
+ product: { title: string };
+ unit_price: number;
+ quantity: number;
+}
+
+export default function AdminOrders() {
+ const { user, isAdmin, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [orders, setOrders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedOrder, setSelectedOrder] = useState(null);
+ const [orderItems, setOrderItems] = useState([]);
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ useEffect(() => {
+ if (!authLoading) {
+ if (!user) navigate('/auth');
+ else if (!isAdmin) navigate('/dashboard');
+ else fetchOrders();
+ }
+ }, [user, isAdmin, authLoading]);
+
+ const fetchOrders = async () => {
+ const { data, error } = await supabase
+ .from('orders')
+ .select('*, profile:profiles(email)')
+ .order('created_at', { ascending: false });
+ if (!error && data) setOrders(data as unknown as Order[]);
+ setLoading(false);
+ };
+
+ const viewOrderDetails = async (order: Order) => {
+ setSelectedOrder(order);
+ const { data } = await supabase
+ .from('order_items')
+ .select('*, product:products(title)')
+ .eq('order_id', order.id);
+ setOrderItems(data as unknown as OrderItem[] || []);
+ setDialogOpen(true);
+ };
+
+ const updateOrderStatus = async (orderId: string, status: 'paid' | 'cancelled') => {
+ const { error } = await supabase.from('orders').update({ payment_status: status }).eq('id', orderId);
+ if (error) {
+ toast({ title: 'Error', description: 'Gagal update status', variant: 'destructive' });
+ } else {
+ if (status === 'paid') {
+ // Grant access for each order item
+ const { data: items } = await supabase.from('order_items').select('product_id').eq('order_id', orderId);
+ const order = orders.find(o => o.id === orderId);
+ if (items && order) {
+ for (const item of items) {
+ await supabase.from('user_access').upsert({
+ user_id: order.user_id,
+ product_id: item.product_id,
+ granted_at: new Date().toISOString(),
+ }, { onConflict: 'user_id,product_id' });
+ }
+ }
+ }
+ toast({ title: 'Berhasil', description: `Status diubah ke ${status}` });
+ fetchOrders();
+ setDialogOpen(false);
+ }
+ };
+
+ const getStatusBadge = (status: string | null) => {
+ switch (status) {
+ case 'paid': return Lunas;
+ case 'pending': return Pending;
+ case 'cancelled': return Dibatalkan;
+ default: return {status};
+ }
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Manajemen Order
+
Kelola semua pesanan
+
+
+
+
+
+
+ ID Order
+ Email
+ Total
+ Metode
+ Status
+ Tanggal
+ Aksi
+
+
+
+ {orders.map((order) => (
+
+ {order.id.slice(0, 8)}
+ {order.profile?.email || '-'}
+ {formatIDR(order.total_amount)}
+ {order.payment_method || '-'}
+ {getStatusBadge(order.payment_status)}
+ {formatDateTime(order.created_at)}
+
+
+
+
+ ))}
+ {orders.length === 0 && (
+
+
+ Belum ada order
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/AdminProducts.tsx b/src/pages/admin/AdminProducts.tsx
new file mode 100644
index 0000000..3dc304f
--- /dev/null
+++ b/src/pages/admin/AdminProducts.tsx
@@ -0,0 +1,313 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '@/hooks/useAuth';
+import { supabase } from '@/integrations/supabase/client';
+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 } from '@/components/ui/card';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { toast } from '@/hooks/use-toast';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Plus, Pencil, Trash2 } from 'lucide-react';
+import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
+import { RichTextEditor } from '@/components/RichTextEditor';
+import { formatIDR } from '@/lib/format';
+
+interface Product {
+ id: string;
+ title: string;
+ slug: string;
+ type: string;
+ description: string;
+ content: string;
+ meeting_link: string | null;
+ recording_url: string | null;
+ price: number;
+ sale_price: number | null;
+ is_active: boolean;
+ consulting_duration_minutes: number | null;
+}
+
+const emptyProduct = {
+ title: '',
+ slug: '',
+ type: 'consulting',
+ description: '',
+ content: '',
+ meeting_link: '',
+ recording_url: '',
+ price: 0,
+ sale_price: null as number | null,
+ is_active: true,
+ consulting_duration_minutes: 60,
+};
+
+export default function AdminProducts() {
+ const { user, isAdmin, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingProduct, setEditingProduct] = useState(null);
+ const [form, setForm] = useState(emptyProduct);
+ const [saving, setSaving] = useState(false);
+ const [activeTab, setActiveTab] = useState('details');
+
+ useEffect(() => {
+ if (!authLoading) {
+ if (!user) navigate('/auth');
+ else if (!isAdmin) navigate('/dashboard');
+ else fetchProducts();
+ }
+ }, [user, isAdmin, authLoading]);
+
+ const fetchProducts = async () => {
+ const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false });
+ if (!error && data) setProducts(data);
+ setLoading(false);
+ };
+
+ const generateSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+
+ const handleEdit = (product: Product) => {
+ setEditingProduct(product);
+ setForm({
+ title: product.title,
+ slug: product.slug,
+ type: product.type,
+ description: product.description,
+ content: product.content || '',
+ meeting_link: product.meeting_link || '',
+ recording_url: product.recording_url || '',
+ price: product.price,
+ sale_price: product.sale_price,
+ is_active: product.is_active,
+ consulting_duration_minutes: product.consulting_duration_minutes || 60,
+ });
+ setActiveTab('details');
+ setDialogOpen(true);
+ };
+
+ const handleNew = () => {
+ setEditingProduct(null);
+ setForm(emptyProduct);
+ setActiveTab('details');
+ setDialogOpen(true);
+ };
+
+ const handleSave = async () => {
+ if (!form.title || !form.slug || form.price <= 0) {
+ toast({ title: 'Validasi error', description: 'Lengkapi semua field wajib', variant: 'destructive' });
+ return;
+ }
+ setSaving(true);
+ const productData = {
+ title: form.title,
+ slug: form.slug,
+ type: form.type,
+ description: form.description,
+ content: form.content,
+ meeting_link: form.meeting_link || null,
+ recording_url: form.recording_url || null,
+ price: form.price,
+ sale_price: form.sale_price || null,
+ is_active: form.is_active,
+ consulting_duration_minutes: form.type === 'consulting' ? form.consulting_duration_minutes : null,
+ };
+
+ if (editingProduct) {
+ const { error } = await supabase.from('products').update(productData).eq('id', editingProduct.id);
+ if (error) toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' });
+ else { toast({ title: 'Berhasil', description: 'Produk diupdate' }); setDialogOpen(false); fetchProducts(); }
+ } else {
+ const { error } = await supabase.from('products').insert(productData);
+ if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
+ else { toast({ title: 'Berhasil', description: 'Produk dibuat' }); setDialogOpen(false); fetchProducts(); }
+ }
+ setSaving(false);
+ };
+
+ const handleDelete = async (id: string) => {
+ if (!confirm('Hapus produk ini?')) return;
+ const { error } = await supabase.from('products').delete().eq('id', id);
+ if (error) toast({ title: 'Error', description: 'Gagal menghapus produk', variant: 'destructive' });
+ else { toast({ title: 'Berhasil', description: 'Produk dihapus' }); fetchProducts(); }
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
Manajemen Produk
+
Kelola semua produk
+
+
+
+
+
+
+
+
+
+ Judul
+ Tipe
+ Harga
+ Status
+ Aksi
+
+
+
+ {products.map((product) => (
+
+ {product.title}
+ {product.type}
+
+ {product.sale_price ? (
+
+ {formatIDR(product.sale_price)}
+ {formatIDR(product.price)}
+
+ ) : (
+ {formatIDR(product.price)}
+ )}
+
+
+
+ {product.is_active ? 'Aktif' : 'Nonaktif'}
+
+
+
+
+
+
+
+ ))}
+ {products.length === 0 && (
+
+
+ Belum ada produk
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/AdminSettings.tsx b/src/pages/admin/AdminSettings.tsx
new file mode 100644
index 0000000..c06ae39
--- /dev/null
+++ b/src/pages/admin/AdminSettings.tsx
@@ -0,0 +1,193 @@
+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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { toast } from '@/hooks/use-toast';
+import { Plus, Pencil, Trash2, Clock } from 'lucide-react';
+
+interface Workhour {
+ id: string;
+ weekday: number;
+ start_time: string;
+ end_time: string;
+}
+
+const WEEKDAYS = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
+
+const emptyWorkhour = { weekday: 1, start_time: '09:00', end_time: '17:00' };
+
+export default function AdminSettings() {
+ const { user, isAdmin, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(true);
+ const [workhours, setWorkhours] = useState([]);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingWorkhour, setEditingWorkhour] = useState(null);
+ const [form, setForm] = useState(emptyWorkhour);
+
+ useEffect(() => {
+ if (!authLoading) {
+ if (!user) navigate('/auth');
+ else if (!isAdmin) navigate('/dashboard');
+ else fetchWorkhours();
+ }
+ }, [user, isAdmin, authLoading]);
+
+ const fetchWorkhours = async () => {
+ const { data, error } = await supabase.from('workhours').select('*').order('weekday');
+ if (!error && data) setWorkhours(data);
+ setLoading(false);
+ };
+
+ const handleNew = () => {
+ setEditingWorkhour(null);
+ setForm(emptyWorkhour);
+ setDialogOpen(true);
+ };
+
+ const handleEdit = (wh: Workhour) => {
+ setEditingWorkhour(wh);
+ setForm({ weekday: wh.weekday, start_time: wh.start_time, end_time: wh.end_time });
+ setDialogOpen(true);
+ };
+
+ const handleSave = async () => {
+ const workhourData = {
+ weekday: form.weekday,
+ start_time: form.start_time,
+ end_time: form.end_time,
+ };
+
+ if (editingWorkhour) {
+ const { error } = await supabase.from('workhours').update(workhourData).eq('id', editingWorkhour.id);
+ if (error) toast({ title: 'Error', description: 'Gagal mengupdate', variant: 'destructive' });
+ else { toast({ title: 'Berhasil', description: 'Jam kerja diupdate' }); setDialogOpen(false); fetchWorkhours(); }
+ } else {
+ const { error } = await supabase.from('workhours').insert(workhourData);
+ if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
+ else { toast({ title: 'Berhasil', description: 'Jam kerja ditambahkan' }); setDialogOpen(false); fetchWorkhours(); }
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ if (!confirm('Hapus jam kerja ini?')) return;
+ const { error } = await supabase.from('workhours').delete().eq('id', id);
+ if (error) toast({ title: 'Error', description: 'Gagal menghapus', variant: 'destructive' });
+ else { toast({ title: 'Berhasil', description: 'Jam kerja dihapus' }); fetchWorkhours(); }
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Pengaturan
+
Konfigurasi platform
+
+
+
+
+
+
+
+ Jam Kerja
+
+ Atur jadwal ketersediaan untuk konsultasi
+
+
+
+
+
+
+
+ Hari
+ Mulai
+ Selesai
+ Aksi
+
+
+
+ {workhours.map((wh) => (
+
+ {WEEKDAYS[wh.weekday]}
+ {wh.start_time}
+ {wh.end_time}
+
+
+
+
+
+ ))}
+ {workhours.length === 0 && (
+
+
+ Belum ada jam kerja
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/member/MemberAccess.tsx b/src/pages/member/MemberAccess.tsx
new file mode 100644
index 0000000..d7f650d
--- /dev/null
+++ b/src/pages/member/MemberAccess.tsx
@@ -0,0 +1,144 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+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';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+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;
+ };
+}
+
+export default function MemberAccess() {
+ const { user, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [access, setAccess] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!authLoading && !user) navigate('/auth');
+ else if (user) fetchAccess();
+ }, [user, authLoading]);
+
+ const fetchAccess = async () => {
+ const { data } = await supabase
+ .from('user_access')
+ .select(`id, granted_at, expires_at, product:products (id, title, slug, type, meeting_link, recording_url, description)`)
+ .eq('user_id', user!.id);
+ if (data) setAccess(data as unknown as UserAccess[]);
+ setLoading(false);
+ };
+
+ const renderAccessActions = (item: UserAccess) => {
+ switch (item.product.type) {
+ case 'consulting':
+ return (
+
+ );
+ case 'webinar':
+ return (
+
+ );
+ case 'bootcamp':
+ return (
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+ {[...Array(3)].map((_, i) => )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
Akses Saya
+
Semua produk yang dapat Anda akses
+
+ {access.length === 0 ? (
+
+
+ Anda belum memiliki akses ke produk apapun
+
+
+
+ ) : (
+
+ {access.map((item) => (
+
+
+
+
+ {item.product.title}
+ {item.product.type}
+
+
Aktif
+
+
+
+ {item.product.description}
+ {renderAccessActions(item)}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/member/MemberDashboard.tsx b/src/pages/member/MemberDashboard.tsx
new file mode 100644
index 0000000..e0d48cf
--- /dev/null
+++ b/src/pages/member/MemberDashboard.tsx
@@ -0,0 +1,199 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+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';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { formatIDR } from '@/lib/format';
+import { Video, Calendar, BookOpen, ArrowRight, Package, Receipt, ShoppingBag } from 'lucide-react';
+
+interface UserAccess {
+ id: string;
+ product: {
+ id: string;
+ title: string;
+ slug: string;
+ type: string;
+ meeting_link: string | null;
+ recording_url: string | null;
+ };
+}
+
+interface Order {
+ id: string;
+ total_amount: number;
+ payment_status: string | null;
+ created_at: string;
+}
+
+export default function MemberDashboard() {
+ const { user, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [access, setAccess] = useState([]);
+ const [recentOrders, setRecentOrders] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!authLoading && !user) navigate('/auth');
+ else if (user) fetchData();
+ }, [user, authLoading]);
+
+ const fetchData = async () => {
+ const [accessRes, ordersRes] = await Promise.all([
+ supabase.from('user_access').select(`id, product:products (id, title, slug, type, meeting_link, recording_url)`).eq('user_id', user!.id),
+ supabase.from('orders').select('*').eq('user_id', user!.id).order('created_at', { ascending: false }).limit(3)
+ ]);
+ if (accessRes.data) setAccess(accessRes.data as unknown as UserAccess[]);
+ if (ordersRes.data) setRecentOrders(ordersRes.data);
+ setLoading(false);
+ };
+
+ const getQuickAction = (item: UserAccess) => {
+ switch (item.product.type) {
+ case 'consulting':
+ return { label: 'Jadwalkan', icon: Calendar, href: item.product.meeting_link };
+ case 'webinar':
+ return { label: 'Tonton', icon: Video, href: item.product.recording_url || item.product.meeting_link };
+ case 'bootcamp':
+ return { label: 'Lanjutkan', icon: BookOpen, route: `/bootcamp/${item.product.slug}` };
+ default:
+ return null;
+ }
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+ {[...Array(4)].map((_, i) => )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
Dashboard
+
Selamat datang kembali!
+
+
+
+
+
+
+
+
{access.length}
+
Produk Diakses
+
+
+
+
+
+
+
+
+
+
{recentOrders.length}
+
Order Terbaru
+
+
+
+
+
+
+
+
+
+
Jelajahi lebih banyak
+
Lihat semua produk
+
+
+
+
+
+
+
+ {access.length > 0 && (
+
+
+
Akses Cepat
+
+
+
+ {access.slice(0, 3).map((item) => {
+ const action = getQuickAction(item);
+ return (
+
+
+ {item.product.title}
+ {item.product.type}
+
+
+ {action && (
+ action.route ? (
+
+ ) : action.href ? (
+
+ ) : null
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ {recentOrders.length > 0 && (
+
+
+
Order Terbaru
+
+
+
+
+ {recentOrders.map((order) => (
+
+
+
{order.id.slice(0, 8)}
+
{new Date(order.created_at).toLocaleDateString('id-ID')}
+
+
+
+ {order.payment_status === 'paid' ? 'Lunas' : 'Pending'}
+
+ {formatIDR(order.total_amount)}
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/member/MemberOrders.tsx b/src/pages/member/MemberOrders.tsx
new file mode 100644
index 0000000..d2f1e01
--- /dev/null
+++ b/src/pages/member/MemberOrders.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { AppLayout } from '@/components/AppLayout';
+import { useAuth } from '@/hooks/useAuth';
+import { supabase } from '@/integrations/supabase/client';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { formatIDR, formatDate } from '@/lib/format';
+
+interface Order {
+ id: string;
+ total_amount: number;
+ status: string;
+ payment_status: string | null;
+ payment_method: string | null;
+ created_at: string;
+}
+
+export default function MemberOrders() {
+ const { user, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [orders, setOrders] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!authLoading && !user) navigate('/auth');
+ else if (user) fetchOrders();
+ }, [user, authLoading]);
+
+ const fetchOrders = async () => {
+ const { data } = await supabase
+ .from('orders')
+ .select('*')
+ .eq('user_id', user!.id)
+ .order('created_at', { ascending: false });
+ if (data) setOrders(data);
+ setLoading(false);
+ };
+
+ const getStatusColor = (status: string) => {
+ 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';
+ case 'cancelled': return 'Dibatalkan';
+ default: return status || 'Pending';
+ }
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+ {[...Array(3)].map((_, i) => )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
Riwayat Order
+
Semua pesanan Anda
+
+ {orders.length === 0 ? (
+
+
+ Belum ada order
+
+
+ ) : (
+
+ {orders.map((order) => (
+
+
+
+
+
{order.id.slice(0, 8)}
+
{formatDate(order.created_at)}
+ {order.payment_method && (
+
{order.payment_method}
+ )}
+
+
+
+ {getPaymentStatusLabel(order.payment_status || order.status)}
+
+ {formatIDR(order.total_amount)}
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/member/MemberProfile.tsx b/src/pages/member/MemberProfile.tsx
new file mode 100644
index 0000000..b344687
--- /dev/null
+++ b/src/pages/member/MemberProfile.tsx
@@ -0,0 +1,134 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { AppLayout } from '@/components/AppLayout';
+import { useAuth } from '@/hooks/useAuth';
+import { supabase } from '@/integrations/supabase/client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton';
+import { toast } from '@/hooks/use-toast';
+import { User, LogOut } from 'lucide-react';
+
+interface Profile {
+ id: string;
+ email: string | null;
+ full_name: string | null;
+ avatar_url: string | null;
+}
+
+export default function MemberProfile() {
+ const { user, signOut, loading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [form, setForm] = useState({ full_name: '', avatar_url: '' });
+
+ useEffect(() => {
+ if (!authLoading && !user) navigate('/auth');
+ else if (user) fetchProfile();
+ }, [user, authLoading]);
+
+ const fetchProfile = async () => {
+ const { data } = await supabase
+ .from('profiles')
+ .select('*')
+ .eq('id', user!.id)
+ .single();
+ if (data) {
+ setProfile(data);
+ setForm({ full_name: data.full_name || '', avatar_url: data.avatar_url || '' });
+ }
+ setLoading(false);
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ const { error } = await supabase
+ .from('profiles')
+ .update({ full_name: form.full_name, avatar_url: form.avatar_url || null })
+ .eq('id', user!.id);
+
+ if (error) {
+ toast({ title: 'Error', description: 'Gagal menyimpan profil', variant: 'destructive' });
+ } else {
+ toast({ title: 'Berhasil', description: 'Profil diperbarui' });
+ fetchProfile();
+ }
+ setSaving(false);
+ };
+
+ const handleSignOut = async () => {
+ await signOut();
+ navigate('/');
+ };
+
+ if (authLoading || loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Profil
+
Kelola informasi akun Anda
+
+
+
+
+ );
+}