From 986c7c69926feedc5ad8049f3497b4c5bf539592 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 03:26:31 +0000 Subject: [PATCH] Changes --- package-lock.json | 10 + package.json | 1 + src/App.tsx | 41 +++- src/pages/Checkout.tsx | 221 ++++++++++++++++--- src/pages/admin/AdminBootcamp.tsx | 91 ++++++++ src/pages/admin/AdminDashboard.tsx | 134 ++++++++++++ src/pages/admin/AdminMembers.tsx | 188 ++++++++++++++++ src/pages/admin/AdminOrders.tsx | 211 ++++++++++++++++++ src/pages/admin/AdminProducts.tsx | 313 +++++++++++++++++++++++++++ src/pages/admin/AdminSettings.tsx | 193 +++++++++++++++++ src/pages/member/MemberAccess.tsx | 144 ++++++++++++ src/pages/member/MemberDashboard.tsx | 199 +++++++++++++++++ src/pages/member/MemberOrders.tsx | 113 ++++++++++ src/pages/member/MemberProfile.tsx | 134 ++++++++++++ 14 files changed, 1953 insertions(+), 40 deletions(-) create mode 100644 src/pages/admin/AdminBootcamp.tsx create mode 100644 src/pages/admin/AdminDashboard.tsx create mode 100644 src/pages/admin/AdminMembers.tsx create mode 100644 src/pages/admin/AdminOrders.tsx create mode 100644 src/pages/admin/AdminProducts.tsx create mode 100644 src/pages/admin/AdminSettings.tsx create mode 100644 src/pages/member/MemberAccess.tsx create mode 100644 src/pages/member/MemberDashboard.tsx create mode 100644 src/pages/member/MemberOrders.tsx create mode 100644 src/pages/member/MemberProfile.tsx diff --git a/package-lock.json b/package-lock.json index 655c25d..2b05158 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "input-otp": "^1.4.2", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -6511,6 +6512,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 062a408..425db0f 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "input-otp": "^1.4.2", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index 6db4b56..df62fa3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,13 +10,25 @@ import Auth from "./pages/Auth"; import Products from "./pages/Products"; import ProductDetail from "./pages/ProductDetail"; import Checkout from "./pages/Checkout"; -import Dashboard from "./pages/Dashboard"; -import Admin from "./pages/Admin"; import Bootcamp from "./pages/Bootcamp"; import Events from "./pages/Events"; -import AdminEvents from "./pages/admin/AdminEvents"; import NotFound from "./pages/NotFound"; +// Member pages +import MemberDashboard from "./pages/member/MemberDashboard"; +import MemberAccess from "./pages/member/MemberAccess"; +import MemberOrders from "./pages/member/MemberOrders"; +import MemberProfile from "./pages/member/MemberProfile"; + +// Admin pages +import AdminDashboard from "./pages/admin/AdminDashboard"; +import AdminProducts from "./pages/admin/AdminProducts"; +import AdminBootcamp from "./pages/admin/AdminBootcamp"; +import AdminOrders from "./pages/admin/AdminOrders"; +import AdminMembers from "./pages/admin/AdminMembers"; +import AdminEvents from "./pages/admin/AdminEvents"; +import AdminSettings from "./pages/admin/AdminSettings"; + const queryClient = new QueryClient(); const App = () => ( @@ -33,15 +45,24 @@ const App = () => ( } /> } /> } /> - } /> - } /> - } /> - } /> } /> - } /> - } /> - } /> } /> + + {/* Member routes */} + } /> + } /> + } /> + } /> + + {/* Admin routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx index e9c46c9..46bc3b9 100644 --- a/src/pages/Checkout.tsx +++ b/src/pages/Checkout.tsx @@ -1,23 +1,70 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; import { toast } from "@/hooks/use-toast"; import { formatIDR } from "@/lib/format"; -import { Trash2, CreditCard, Loader2 } from "lucide-react"; +import { Trash2, CreditCard, Loader2, QrCode, Wallet } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; -// Pakasir configuration - replace with your actual project slug -const PAKASIR_PROJECT_SLUG = "dewengoding"; // TODO: Replace with actual Pakasir project slug +// Pakasir configuration +const PAKASIR_PROJECT_SLUG = "dewengoding"; +const PAKASIR_API_KEY = "your-pakasir-api-key"; // TODO: Move to secrets + +type PaymentMethod = "qris" | "paypal"; +type CheckoutStep = "cart" | "payment" | "waiting"; + +interface PaymentData { + qr_string?: string; + payment_url?: string; + expired_at?: string; + order_id?: string; +} export default function Checkout() { const { items, removeItem, clearCart, total } = useCart(); const { user } = useAuth(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [loading, setLoading] = useState(false); + const [step, setStep] = useState("cart"); + const [paymentMethod, setPaymentMethod] = useState("qris"); + const [paymentData, setPaymentData] = useState(null); + const [orderId, setOrderId] = useState(null); + const [checkingStatus, setCheckingStatus] = useState(false); + + // Check for returning from PayPal + useEffect(() => { + const returnedOrderId = searchParams.get("order_id"); + if (returnedOrderId) { + setOrderId(returnedOrderId); + checkPaymentStatus(returnedOrderId); + } + }, [searchParams]); + + const checkPaymentStatus = async (oid: string) => { + setCheckingStatus(true); + const { data: order } = await supabase + .from("orders") + .select("payment_status") + .eq("id", oid) + .single(); + + if (order?.payment_status === "paid") { + toast({ title: "Pembayaran berhasil!", description: "Akses produk sudah aktif" }); + navigate("/access"); + } else { + toast({ title: "Pembayaran pending", description: "Menunggu konfirmasi pembayaran" }); + } + setCheckingStatus(false); + }; const handleCheckout = async () => { if (!user) { @@ -27,11 +74,7 @@ export default function Checkout() { } if (items.length === 0) { - toast({ - title: "Keranjang kosong", - description: "Tambahkan produk ke keranjang terlebih dahulu", - variant: "destructive", - }); + toast({ title: "Keranjang kosong", description: "Tambahkan produk ke keranjang terlebih dahulu", variant: "destructive" }); return; } @@ -40,17 +83,19 @@ export default function Checkout() { try { // Generate a unique order reference const orderRef = `ORD${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substring(2, 6).toUpperCase()}`; + const amountInRupiah = Math.round(total); // Create order with pending payment status const { data: order, error: orderError } = await supabase .from("orders") .insert({ user_id: user.id, - total_amount: total, + total_amount: amountInRupiah, status: "pending", payment_provider: "pakasir", payment_reference: orderRef, payment_status: "pending", + payment_method: paymentMethod, }) .select() .single(); @@ -68,25 +113,52 @@ export default function Checkout() { })); const { error: itemsError } = await supabase.from("order_items").insert(orderItems); + if (itemsError) throw new Error("Gagal menambahkan item order"); - if (itemsError) { - throw new Error("Gagal menambahkan item order"); + setOrderId(order.id); + + if (paymentMethod === "qris") { + // Call Pakasir API for QRIS + try { + const response = await fetch(`https://app.pakasir.com/api/transactioncreate/qris`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + project: PAKASIR_PROJECT_SLUG, + order_id: order.id, + amount: amountInRupiah, + api_key: PAKASIR_API_KEY, + }), + }); + + const result = await response.json(); + + if (result.qr_string || result.qr) { + setPaymentData({ + qr_string: result.qr_string || result.qr, + expired_at: result.expired_at, + order_id: order.id, + }); + setStep("waiting"); + clearCart(); + } else { + // Fallback to redirect if API doesn't return QR + const pakasirUrl = `https://app.pakasir.com/pay/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`; + clearCart(); + window.location.href = pakasirUrl; + } + } catch { + // Fallback to redirect + const pakasirUrl = `https://app.pakasir.com/pay/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`; + clearCart(); + window.location.href = pakasirUrl; + } + } else { + // PayPal - redirect to Pakasir PayPal URL + clearCart(); + const paypalUrl = `https://app.pakasir.com/paypal/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`; + window.location.href = paypalUrl; } - - // 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({ @@ -99,6 +171,65 @@ export default function Checkout() { } }; + const refreshPaymentStatus = async () => { + if (!orderId) return; + setCheckingStatus(true); + + const { data: order } = await supabase + .from("orders") + .select("payment_status") + .eq("id", orderId) + .single(); + + if (order?.payment_status === "paid") { + toast({ title: "Pembayaran berhasil!", description: "Akses produk sudah aktif" }); + navigate("/access"); + } else { + toast({ title: "Belum ada pembayaran", description: "Silakan selesaikan pembayaran" }); + } + setCheckingStatus(false); + }; + + // Waiting for QRIS payment + if (step === "waiting" && paymentData) { + return ( + +
+ + + Scan QR Code untuk Bayar + + +
+ +
+
+

{formatIDR(total)}

+ {paymentData.expired_at && ( +

+ Berlaku hingga: {new Date(paymentData.expired_at).toLocaleString("id-ID")} +

+ )} +
+
+ + +
+

+ Pembayaran akan otomatis terkonfirmasi setelah Anda scan dan bayar QR code di atas +

+
+
+
+
+ ); + } + return (
@@ -134,6 +265,36 @@ export default function Checkout() { ))} + + + + Metode Pembayaran + + + setPaymentMethod(v as PaymentMethod)} className="space-y-3"> +
+ + +
+
+ + +
+
+
+
@@ -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 + + + )} + +
+
+
+ + + + + Detail Member + + {selectedMember && ( +
+
+

Email: {selectedMember.email}

+

Nama: {selectedMember.full_name || '-'}

+

ID: {selectedMember.id}

+
+
+

Akses Produk:

+ {memberAccess.length === 0 ? ( +

Tidak ada akses

+ ) : ( +
+ {memberAccess.map((access) => ( +
+ {access.product?.title} + {formatDateTime(access.granted_at)} +
+ ))} +
+ )} +
+
+ )} +
+
+
+
+ ); +} 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 + + + )} + +
+
+
+ + + + + Detail Order + + {selectedOrder && ( +
+
+
ID: {selectedOrder.id.slice(0, 8)}
+
Referensi: {selectedOrder.payment_reference || '-'}
+
Email: {selectedOrder.profile?.email}
+
Metode: {selectedOrder.payment_method || '-'}
+
+
+

Item:

+ {orderItems.map((item) => ( +
+ {item.product?.title} + {formatIDR(item.unit_price)} +
+ ))} +
+ Total + {formatIDR(selectedOrder.total_amount)} +
+
+
+ {selectedOrder.payment_status !== 'paid' && ( + + )} + {selectedOrder.payment_status !== 'cancelled' && ( + + )} +
+
+ )} +
+
+
+
+ ); +} 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 + + + )} + +
+
+
+ + + + + {editingProduct ? 'Edit Produk' : 'Produk Baru'} + + + + Detail + {editingProduct && form.type === 'bootcamp' && Kurikulum} + + +
+
+ + setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" /> +
+
+ + setForm({ ...form, slug: e.target.value })} className="border-2" /> +
+
+
+ + +
+ {form.type === 'consulting' && ( +
+ + setForm({ ...form, consulting_duration_minutes: parseInt(e.target.value) || 60 })} + className="border-2" + /> +
+ )} +
+ + setForm({ ...form, description: v })} /> +
+
+ + setForm({ ...form, content: v })} /> +
+
+
+ + setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" /> +
+
+ + setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" /> +
+
+
+
+ + setForm({ ...form, price: parseFloat(e.target.value) || 0 })} className="border-2" /> +
+
+ + setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })} placeholder="Kosongkan jika tidak promo" className="border-2" /> +
+
+
+ setForm({ ...form, is_active: checked })} /> + +
+ +
+ {editingProduct && form.type === 'bootcamp' && ( + + + + )} +
+
+
+
+
+ ); +} 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 + + + )} + +
+
+
+
+ + + + + {editingWorkhour ? 'Edit Jam Kerja' : 'Tambah Jam Kerja'} + +
+
+ + +
+
+
+ + setForm({ ...form, start_time: e.target.value })} className="border-2" /> +
+
+ + setForm({ ...form, end_time: e.target.value })} className="border-2" /> +
+
+ +
+
+
+
+
+ ); +} 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 ( +
+ {item.product.meeting_link && ( + + )} + {item.product.recording_url && ( + + )} +
+ ); + 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

+ +
+ + + + + Informasi Akun + + + +
+ + +
+
+ + setForm({ ...form, full_name: e.target.value })} + className="border-2" + placeholder="Masukkan nama lengkap" + /> +
+
+ + setForm({ ...form, avatar_url: e.target.value })} + className="border-2" + placeholder="https://..." + /> +
+ +
+
+ + + + + + +
+
+
+ ); +}