Changes
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -51,6 +51,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -6511,6 +6512,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
41
src/App.tsx
41
src/App.tsx
@@ -10,13 +10,25 @@ import Auth from "./pages/Auth";
|
|||||||
import Products from "./pages/Products";
|
import Products from "./pages/Products";
|
||||||
import ProductDetail from "./pages/ProductDetail";
|
import ProductDetail from "./pages/ProductDetail";
|
||||||
import Checkout from "./pages/Checkout";
|
import Checkout from "./pages/Checkout";
|
||||||
import Dashboard from "./pages/Dashboard";
|
|
||||||
import Admin from "./pages/Admin";
|
|
||||||
import Bootcamp from "./pages/Bootcamp";
|
import Bootcamp from "./pages/Bootcamp";
|
||||||
import Events from "./pages/Events";
|
import Events from "./pages/Events";
|
||||||
import AdminEvents from "./pages/admin/AdminEvents";
|
|
||||||
import NotFound from "./pages/NotFound";
|
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 queryClient = new QueryClient();
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
@@ -33,15 +45,24 @@ const App = () => (
|
|||||||
<Route path="/products" element={<Products />} />
|
<Route path="/products" element={<Products />} />
|
||||||
<Route path="/products/:slug" element={<ProductDetail />} />
|
<Route path="/products/:slug" element={<ProductDetail />} />
|
||||||
<Route path="/checkout" element={<Checkout />} />
|
<Route path="/checkout" element={<Checkout />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="/access" element={<Dashboard />} />
|
|
||||||
<Route path="/orders" element={<Dashboard />} />
|
|
||||||
<Route path="/profile" element={<Dashboard />} />
|
|
||||||
<Route path="/events" element={<Events />} />
|
<Route path="/events" element={<Events />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
|
||||||
<Route path="/admin/products" element={<Admin />} />
|
|
||||||
<Route path="/admin/events" element={<AdminEvents />} />
|
|
||||||
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
||||||
|
|
||||||
|
{/* Member routes */}
|
||||||
|
<Route path="/dashboard" element={<MemberDashboard />} />
|
||||||
|
<Route path="/access" element={<MemberAccess />} />
|
||||||
|
<Route path="/orders" element={<MemberOrders />} />
|
||||||
|
<Route path="/profile" element={<MemberProfile />} />
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route path="/admin" element={<AdminDashboard />} />
|
||||||
|
<Route path="/admin/products" element={<AdminProducts />} />
|
||||||
|
<Route path="/admin/bootcamp" element={<AdminBootcamp />} />
|
||||||
|
<Route path="/admin/orders" element={<AdminOrders />} />
|
||||||
|
<Route path="/admin/members" element={<AdminMembers />} />
|
||||||
|
<Route path="/admin/events" element={<AdminEvents />} />
|
||||||
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,23 +1,70 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { AppLayout } from "@/components/AppLayout";
|
import { AppLayout } from "@/components/AppLayout";
|
||||||
import { useCart } from "@/contexts/CartContext";
|
import { useCart } from "@/contexts/CartContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { toast } from "@/hooks/use-toast";
|
||||||
import { formatIDR } from "@/lib/format";
|
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
|
// Pakasir configuration
|
||||||
const PAKASIR_PROJECT_SLUG = "dewengoding"; // TODO: Replace with actual Pakasir project slug
|
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() {
|
export default function Checkout() {
|
||||||
const { items, removeItem, clearCart, total } = useCart();
|
const { items, removeItem, clearCart, total } = useCart();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [step, setStep] = useState<CheckoutStep>("cart");
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("qris");
|
||||||
|
const [paymentData, setPaymentData] = useState<PaymentData | null>(null);
|
||||||
|
const [orderId, setOrderId] = useState<string | null>(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 () => {
|
const handleCheckout = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -27,11 +74,7 @@ export default function Checkout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
toast({
|
toast({ title: "Keranjang kosong", description: "Tambahkan produk ke keranjang terlebih dahulu", variant: "destructive" });
|
||||||
title: "Keranjang kosong",
|
|
||||||
description: "Tambahkan produk ke keranjang terlebih dahulu",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +83,19 @@ export default function Checkout() {
|
|||||||
try {
|
try {
|
||||||
// Generate a unique order reference
|
// Generate a unique order reference
|
||||||
const orderRef = `ORD${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
|
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
|
// Create order with pending payment status
|
||||||
const { data: order, error: orderError } = await supabase
|
const { data: order, error: orderError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
total_amount: total,
|
total_amount: amountInRupiah,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
payment_provider: "pakasir",
|
payment_provider: "pakasir",
|
||||||
payment_reference: orderRef,
|
payment_reference: orderRef,
|
||||||
payment_status: "pending",
|
payment_status: "pending",
|
||||||
|
payment_method: paymentMethod,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -68,25 +113,52 @@ export default function Checkout() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const { error: itemsError } = await supabase.from("order_items").insert(orderItems);
|
const { error: itemsError } = await supabase.from("order_items").insert(orderItems);
|
||||||
|
if (itemsError) throw new Error("Gagal menambahkan item order");
|
||||||
|
|
||||||
if (itemsError) {
|
setOrderId(order.id);
|
||||||
throw new Error("Gagal menambahkan item order");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build Pakasir payment URL
|
if (paymentMethod === "qris") {
|
||||||
const amountInRupiah = Math.round(total);
|
// Call Pakasir API for QRIS
|
||||||
const pakasirUrl = `https://app.pakasir.com/pay/${PAKASIR_PROJECT_SLUG}/${amountInRupiah}?order_id=${order.id}`;
|
try {
|
||||||
|
const response = await fetch(`https://app.pakasir.com/api/transactioncreate/qris`, {
|
||||||
// Clear cart and redirect to Pakasir
|
method: "POST",
|
||||||
clearCart();
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
toast({
|
project: PAKASIR_PROJECT_SLUG,
|
||||||
title: "Mengarahkan ke pembayaran...",
|
order_id: order.id,
|
||||||
description: "Anda akan diarahkan ke halaman pembayaran Pakasir",
|
amount: amountInRupiah,
|
||||||
|
api_key: PAKASIR_API_KEY,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to Pakasir payment page
|
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;
|
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;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Checkout error:", error);
|
console.error("Checkout error:", error);
|
||||||
toast({
|
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 (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-md">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>Scan QR Code untuk Bayar</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center space-y-6">
|
||||||
|
<div className="bg-white p-4 rounded-lg">
|
||||||
|
<QRCodeSVG value={paymentData.qr_string || ""} size={200} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold">{formatIDR(total)}</p>
|
||||||
|
{paymentData.expired_at && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Berlaku hingga: {new Date(paymentData.expired_at).toLocaleString("id-ID")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Button onClick={refreshPaymentStatus} variant="outline" className="w-full border-2" disabled={checkingStatus}>
|
||||||
|
{checkingStatus ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||||
|
Cek Status Pembayaran
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/dashboard")} variant="ghost" className="w-full">
|
||||||
|
Kembali ke Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Pembayaran akan otomatis terkonfirmasi setelah Anda scan dan bayar QR code di atas
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
@@ -134,6 +265,36 @@ export default function Checkout() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metode Pembayaran</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RadioGroup value={paymentMethod} onValueChange={(v) => setPaymentMethod(v as PaymentMethod)} className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-3 p-3 border-2 border-border rounded-none hover:bg-muted cursor-pointer">
|
||||||
|
<RadioGroupItem value="qris" id="qris" />
|
||||||
|
<Label htmlFor="qris" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||||
|
<QrCode className="w-5 h-5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">QRIS</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Scan QR dengan aplikasi e-wallet atau mobile banking</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3 p-3 border-2 border-border rounded-none hover:bg-muted cursor-pointer">
|
||||||
|
<RadioGroupItem value="paypal" id="paypal" />
|
||||||
|
<Label htmlFor="paypal" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||||
|
<Wallet className="w-5 h-5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">PayPal</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Bayar dengan akun PayPal Anda</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -155,14 +316,14 @@ export default function Checkout() {
|
|||||||
) : user ? (
|
) : user ? (
|
||||||
<>
|
<>
|
||||||
<CreditCard className="w-4 h-4 mr-2" />
|
<CreditCard className="w-4 h-4 mr-2" />
|
||||||
Bayar Sekarang
|
Bayar dengan {paymentMethod === "qris" ? "QRIS" : "PayPal"}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Login untuk Checkout"
|
"Login untuk Checkout"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
Pembayaran diproses melalui Pakasir (QRIS, Transfer Bank)
|
Pembayaran diproses melalui Pakasir
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
91
src/pages/admin/AdminBootcamp.tsx
Normal file
91
src/pages/admin/AdminBootcamp.tsx
Normal file
@@ -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<Product[]>([]);
|
||||||
|
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 (
|
||||||
|
<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">
|
||||||
|
<BookOpen className="w-8 h-8" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold">Manajemen Bootcamp</h1>
|
||||||
|
<p className="text-muted-foreground">Kelola kurikulum bootcamp</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bootcamps.length === 0 ? (
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground mb-4">Belum ada bootcamp. Buat produk dengan tipe bootcamp terlebih dahulu.</p>
|
||||||
|
<Button onClick={() => navigate('/admin/products')} variant="outline" className="border-2">
|
||||||
|
Ke Manajemen Produk
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Accordion type="single" collapsible className="space-y-4">
|
||||||
|
{bootcamps.map((bootcamp) => (
|
||||||
|
<AccordionItem key={bootcamp.id} value={bootcamp.id} className="border-2 border-border bg-card">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<span className="font-bold">{bootcamp.title}</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<CurriculumEditor productId={bootcamp.id} />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/pages/admin/AdminDashboard.tsx
Normal file
134
src/pages/admin/AdminDashboard.tsx
Normal file
@@ -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<Stats | null>(null);
|
||||||
|
const [recentOrders, setRecentOrders] = useState<any[]>([]);
|
||||||
|
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 (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-32" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Admin Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Ringkasan statistik platform</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<Card key={stat.label} className="border-2 border-border">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<stat.icon className={`w-8 h-8 ${stat.color}`} />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stat.value}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Order Terbaru</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentOrders.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-center py-8">Belum ada order</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentOrders.map((order) => (
|
||||||
|
<div key={order.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm">{order.id.slice(0, 8)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{new Date(order.created_at).toLocaleDateString('id-ID')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold">{formatIDR(order.total_amount)}</p>
|
||||||
|
<span className={`text-xs px-2 py-0.5 ${order.payment_status === 'paid' ? 'bg-accent text-accent-foreground' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{order.payment_status === 'paid' ? 'Lunas' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
src/pages/admin/AdminMembers.tsx
Normal file
188
src/pages/admin/AdminMembers.tsx
Normal file
@@ -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<Member[]>([]);
|
||||||
|
const [adminIds, setAdminIds] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedMember, setSelectedMember] = useState<Member | null>(null);
|
||||||
|
const [memberAccess, setMemberAccess] = useState<UserAccess[]>([]);
|
||||||
|
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 (
|
||||||
|
<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">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Manajemen Member</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Kelola semua pengguna</p>
|
||||||
|
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Nama</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Bergabung</TableHead>
|
||||||
|
<TableHead className="text-right">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<TableRow key={member.id}>
|
||||||
|
<TableCell>{member.email || '-'}</TableCell>
|
||||||
|
<TableCell>{member.full_name || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{adminIds.has(member.id) ? (
|
||||||
|
<Badge className="bg-primary">Admin</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-secondary">Member</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDateTime(member.created_at)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => viewMemberDetails(member)}>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleAdminRole(member.id, adminIds.has(member.id))}
|
||||||
|
disabled={member.id === user?.id}
|
||||||
|
>
|
||||||
|
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{members.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
Belum ada member
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-lg border-2 border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Detail Member</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedMember && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<p><span className="text-muted-foreground">Email:</span> {selectedMember.email}</p>
|
||||||
|
<p><span className="text-muted-foreground">Nama:</span> {selectedMember.full_name || '-'}</p>
|
||||||
|
<p><span className="text-muted-foreground">ID:</span> {selectedMember.id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<p className="font-medium mb-2">Akses Produk:</p>
|
||||||
|
{memberAccess.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Tidak ada akses</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{memberAccess.map((access) => (
|
||||||
|
<div key={access.id} className="flex justify-between text-sm">
|
||||||
|
<span>{access.product?.title}</span>
|
||||||
|
<span className="text-muted-foreground">{formatDateTime(access.granted_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
src/pages/admin/AdminOrders.tsx
Normal file
211
src/pages/admin/AdminOrders.tsx
Normal file
@@ -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<Order[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
||||||
|
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
|
||||||
|
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 <Badge className="bg-accent">Lunas</Badge>;
|
||||||
|
case 'pending': return <Badge className="bg-secondary">Pending</Badge>;
|
||||||
|
case 'cancelled': return <Badge className="bg-destructive">Dibatalkan</Badge>;
|
||||||
|
default: return <Badge className="bg-muted">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Manajemen Order</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Kelola semua pesanan</p>
|
||||||
|
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID Order</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Total</TableHead>
|
||||||
|
<TableHead>Metode</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Tanggal</TableHead>
|
||||||
|
<TableHead className="text-right">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{order.id.slice(0, 8)}</TableCell>
|
||||||
|
<TableCell>{order.profile?.email || '-'}</TableCell>
|
||||||
|
<TableCell className="font-bold">{formatIDR(order.total_amount)}</TableCell>
|
||||||
|
<TableCell className="uppercase text-sm">{order.payment_method || '-'}</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(order.payment_status)}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(order.created_at)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => viewOrderDetails(order)}>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{orders.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
|
Belum ada order
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-lg border-2 border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Detail Order</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedOrder && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">ID:</span> {selectedOrder.id.slice(0, 8)}</div>
|
||||||
|
<div><span className="text-muted-foreground">Referensi:</span> {selectedOrder.payment_reference || '-'}</div>
|
||||||
|
<div><span className="text-muted-foreground">Email:</span> {selectedOrder.profile?.email}</div>
|
||||||
|
<div><span className="text-muted-foreground">Metode:</span> {selectedOrder.payment_method || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<p className="font-medium mb-2">Item:</p>
|
||||||
|
{orderItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex justify-between py-1">
|
||||||
|
<span>{item.product?.title}</span>
|
||||||
|
<span className="font-bold">{formatIDR(item.unit_price)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-between pt-2 border-t border-border mt-2">
|
||||||
|
<span className="font-bold">Total</span>
|
||||||
|
<span className="font-bold">{formatIDR(selectedOrder.total_amount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
{selectedOrder.payment_status !== 'paid' && (
|
||||||
|
<Button onClick={() => updateOrderStatus(selectedOrder.id, 'paid')} className="flex-1">
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Tandai Lunas
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedOrder.payment_status !== 'cancelled' && (
|
||||||
|
<Button variant="outline" onClick={() => updateOrderStatus(selectedOrder.id, 'cancelled')} className="flex-1 border-2">
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
Batalkan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
313
src/pages/admin/AdminProducts.tsx
Normal file
313
src/pages/admin/AdminProducts.tsx
Normal file
@@ -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<Product[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
|
const [form, setForm] = useState(emptyProduct);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('details');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading) {
|
||||||
|
if (!user) navigate('/auth');
|
||||||
|
else if (!isAdmin) 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 (
|
||||||
|
<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 justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold">Manajemen Produk</h1>
|
||||||
|
<p className="text-muted-foreground">Kelola semua produk</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleNew} className="shadow-sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Tambah Produk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Judul</TableHead>
|
||||||
|
<TableHead>Tipe</TableHead>
|
||||||
|
<TableHead>Harga</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{products.map((product) => (
|
||||||
|
<TableRow key={product.id}>
|
||||||
|
<TableCell className="font-medium">{product.title}</TableCell>
|
||||||
|
<TableCell className="capitalize">{product.type}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{product.sale_price ? (
|
||||||
|
<span>
|
||||||
|
<span className="font-bold">{formatIDR(product.sale_price)}</span>
|
||||||
|
<span className="text-muted-foreground line-through ml-2">{formatIDR(product.price)}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold">{formatIDR(product.price)}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={product.is_active ? 'text-foreground' : 'text-muted-foreground'}>
|
||||||
|
{product.is_active ? 'Aktif' : 'Nonaktif'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleDelete(product.id)}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{products.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
Belum ada produk
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto border-2 border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingProduct ? 'Edit Produk' : 'Produk Baru'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
|
||||||
|
<TabsList className="border-2 border-border">
|
||||||
|
<TabsTrigger value="details">Detail</TabsTrigger>
|
||||||
|
{editingProduct && form.type === 'bootcamp' && <TabsTrigger value="curriculum">Kurikulum</TabsTrigger>}
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="details" className="space-y-4 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Judul *</Label>
|
||||||
|
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Slug *</Label>
|
||||||
|
<Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tipe</Label>
|
||||||
|
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||||
|
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="consulting">Consulting</SelectItem>
|
||||||
|
<SelectItem value="webinar">Webinar</SelectItem>
|
||||||
|
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{form.type === 'consulting' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Durasi Konsultasi (menit)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.consulting_duration_minutes || 60}
|
||||||
|
onChange={(e) => setForm({ ...form, consulting_duration_minutes: parseInt(e.target.value) || 60 })}
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Deskripsi</Label>
|
||||||
|
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Konten</Label>
|
||||||
|
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Meeting Link</Label>
|
||||||
|
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Recording URL</Label>
|
||||||
|
<Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Harga *</Label>
|
||||||
|
<Input type="number" value={form.price} onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} className="border-2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Harga Promo</Label>
|
||||||
|
<Input type="number" value={form.sale_price || ''} onChange={(e) => setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })} placeholder="Kosongkan jika tidak promo" className="border-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm({ ...form, is_active: checked })} />
|
||||||
|
<Label>Aktif</Label>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} className="w-full shadow-sm" disabled={saving}>
|
||||||
|
{saving ? 'Menyimpan...' : 'Simpan Produk'}
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
{editingProduct && form.type === 'bootcamp' && (
|
||||||
|
<TabsContent value="curriculum" className="py-4">
|
||||||
|
<CurriculumEditor productId={editingProduct.id} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
src/pages/admin/AdminSettings.tsx
Normal file
193
src/pages/admin/AdminSettings.tsx
Normal file
@@ -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<Workhour[]>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingWorkhour, setEditingWorkhour] = useState<Workhour | null>(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 (
|
||||||
|
<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">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Pengaturan</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Konfigurasi platform</p>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5" />
|
||||||
|
Jam Kerja
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Atur jadwal ketersediaan untuk konsultasi</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleNew} className="shadow-sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Tambah Jam Kerja
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Hari</TableHead>
|
||||||
|
<TableHead>Mulai</TableHead>
|
||||||
|
<TableHead>Selesai</TableHead>
|
||||||
|
<TableHead className="text-right">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{workhours.map((wh) => (
|
||||||
|
<TableRow key={wh.id}>
|
||||||
|
<TableCell className="font-medium">{WEEKDAYS[wh.weekday]}</TableCell>
|
||||||
|
<TableCell>{wh.start_time}</TableCell>
|
||||||
|
<TableCell>{wh.end_time}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(wh)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleDelete(wh.id)}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{workhours.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||||
|
Belum ada jam kerja
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md border-2 border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingWorkhour ? 'Edit Jam Kerja' : 'Tambah Jam Kerja'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Hari</Label>
|
||||||
|
<Select value={form.weekday.toString()} onValueChange={(v) => setForm({ ...form, weekday: parseInt(v) })}>
|
||||||
|
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WEEKDAYS.map((day, i) => (
|
||||||
|
<SelectItem key={i} value={i.toString()}>{day}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mulai</Label>
|
||||||
|
<Input type="time" value={form.start_time} onChange={(e) => setForm({ ...form, start_time: e.target.value })} className="border-2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Selesai</Label>
|
||||||
|
<Input type="time" value={form.end_time} onChange={(e) => setForm({ ...form, end_time: e.target.value })} className="border-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} className="w-full shadow-sm">Simpan</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/pages/member/MemberAccess.tsx
Normal file
144
src/pages/member/MemberAccess.tsx
Normal file
@@ -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<UserAccess[]>([]);
|
||||||
|
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 (
|
||||||
|
<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 flex-wrap">
|
||||||
|
{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 (
|
||||||
|
<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" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Akses Saya</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Semua produk yang dapat Anda akses</p>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
src/pages/member/MemberDashboard.tsx
Normal file
199
src/pages/member/MemberDashboard.tsx
Normal file
@@ -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<UserAccess[]>([]);
|
||||||
|
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
|
||||||
|
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 (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-32" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">Selamat datang kembali!</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Package className="w-10 h-10 text-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold">{access.length}</p>
|
||||||
|
<p className="text-muted-foreground">Produk Diakses</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Receipt className="w-10 h-10 text-accent" />
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold">{recentOrders.length}</p>
|
||||||
|
<p className="text-muted-foreground">Order Terbaru</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-2 border-border col-span-full lg:col-span-1">
|
||||||
|
<CardContent className="pt-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ShoppingBag className="w-10 h-10 text-secondary-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Jelajahi lebih banyak</p>
|
||||||
|
<p className="font-medium">Lihat semua produk</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => navigate('/products')} className="border-2">
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{access.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-2xl font-bold">Akses Cepat</h2>
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/access')}>
|
||||||
|
Lihat Semua
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{access.slice(0, 3).map((item) => {
|
||||||
|
const action = getQuickAction(item);
|
||||||
|
return (
|
||||||
|
<Card key={item.id} className="border-2 border-border">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-lg">{item.product.title}</CardTitle>
|
||||||
|
<CardDescription className="capitalize">{item.product.type}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{action && (
|
||||||
|
action.route ? (
|
||||||
|
<Button onClick={() => navigate(action.route!)} className="w-full shadow-sm">
|
||||||
|
<action.icon className="w-4 h-4 mr-2" />
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
) : action.href ? (
|
||||||
|
<Button asChild variant="outline" className="w-full border-2">
|
||||||
|
<a href={action.href} target="_blank" rel="noopener noreferrer">
|
||||||
|
<action.icon className="w-4 h-4 mr-2" />
|
||||||
|
{action.label}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recentOrders.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-2xl font-bold">Order Terbaru</h2>
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/orders')}>
|
||||||
|
Lihat Semua
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
|
{recentOrders.map((order) => (
|
||||||
|
<div key={order.id} className="flex items-center justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm">{order.id.slice(0, 8)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{new Date(order.created_at).toLocaleDateString('id-ID')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge className={order.payment_status === 'paid' ? 'bg-accent' : 'bg-muted'}>
|
||||||
|
{order.payment_status === 'paid' ? 'Lunas' : 'Pending'}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/pages/member/MemberOrders.tsx
Normal file
113
src/pages/member/MemberOrders.tsx
Normal file
@@ -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<Order[]>([]);
|
||||||
|
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 (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Riwayat Order</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Semua pesanan Anda</p>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
{order.payment_method && (
|
||||||
|
<p className="text-xs text-muted-foreground uppercase">{order.payment_method}</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/pages/member/MemberProfile.tsx
Normal file
134
src/pages/member/MemberProfile.tsx
Normal file
@@ -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<Profile | null>(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 (
|
||||||
|
<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 max-w-md" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Profil</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Kelola informasi akun Anda</p>
|
||||||
|
|
||||||
|
<div className="max-w-md space-y-6">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
Informasi Akun
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input value={profile?.email || ''} disabled className="border-2 bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nama Lengkap</Label>
|
||||||
|
<Input
|
||||||
|
value={form.full_name}
|
||||||
|
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
||||||
|
className="border-2"
|
||||||
|
placeholder="Masukkan nama lengkap"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>URL Avatar</Label>
|
||||||
|
<Input
|
||||||
|
value={form.avatar_url}
|
||||||
|
onChange={(e) => setForm({ ...form, avatar_url: e.target.value })}
|
||||||
|
className="border-2"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} disabled={saving} className="w-full shadow-sm">
|
||||||
|
{saving ? 'Menyimpan...' : 'Simpan Profil'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Button variant="outline" onClick={handleSignOut} className="w-full border-2 text-destructive hover:text-destructive">
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user