Implement collaboration wallets, withdrawals, and app UI flows

This commit is contained in:
dwindown
2026-02-03 16:03:11 +07:00
parent 8e64780f72
commit 52b16dce07
16 changed files with 3039 additions and 28 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@ import MemberAccess from "./pages/member/MemberAccess";
import MemberOrders from "./pages/member/MemberOrders"; import MemberOrders from "./pages/member/MemberOrders";
import MemberProfile from "./pages/member/MemberProfile"; import MemberProfile from "./pages/member/MemberProfile";
import OrderDetail from "./pages/member/OrderDetail"; import OrderDetail from "./pages/member/OrderDetail";
import MemberProfit from "./pages/member/MemberProfit";
// Admin pages // Admin pages
import AdminDashboard from "./pages/admin/AdminDashboard"; import AdminDashboard from "./pages/admin/AdminDashboard";
@@ -40,6 +41,7 @@ import AdminSettings from "./pages/admin/AdminSettings";
import AdminConsulting from "./pages/admin/AdminConsulting"; import AdminConsulting from "./pages/admin/AdminConsulting";
import AdminReviews from "./pages/admin/AdminReviews"; import AdminReviews from "./pages/admin/AdminReviews";
import ProductCurriculum from "./pages/admin/ProductCurriculum"; import ProductCurriculum from "./pages/admin/ProductCurriculum";
import AdminWithdrawals from "./pages/admin/AdminWithdrawals";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -108,6 +110,14 @@ const App = () => (
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/profit"
element={
<ProtectedRoute>
<MemberProfit />
</ProtectedRoute>
}
/>
{/* Admin routes */} {/* Admin routes */}
<Route <Route
@@ -190,6 +200,14 @@ const App = () => (
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin/withdrawals"
element={
<ProtectedRoute requireAdmin>
<AdminWithdrawals />
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />

View File

@@ -1,8 +1,9 @@
import { ReactNode, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useCart } from '@/contexts/CartContext'; import { useCart } from '@/contexts/CartContext';
import { useBranding } from '@/hooks/useBranding'; import { useBranding } from '@/hooks/useBranding';
import { supabase } from '@/integrations/supabase/client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Footer } from '@/components/Footer'; import { Footer } from '@/components/Footer';
@@ -24,6 +25,7 @@ import {
X, X,
Video, Video,
Star, Star,
Wallet,
} from 'lucide-react'; } from 'lucide-react';
interface NavItem { interface NavItem {
@@ -46,6 +48,7 @@ const adminNavItems: NavItem[] = [
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video }, { label: 'Konsultasi', href: '/admin/consulting', icon: Video },
{ label: 'Order', href: '/admin/orders', icon: Receipt }, { label: 'Order', href: '/admin/orders', icon: Receipt },
{ label: 'Member', href: '/admin/members', icon: Users }, { label: 'Member', href: '/admin/members', icon: Users },
{ label: 'Withdrawals', href: '/admin/withdrawals', icon: Wallet },
{ label: 'Ulasan', href: '/admin/reviews', icon: Star }, { label: 'Ulasan', href: '/admin/reviews', icon: Star },
{ label: 'Kalender', href: '/admin/events', icon: Calendar }, { label: 'Kalender', href: '/admin/events', icon: Calendar },
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings }, { label: 'Pengaturan', href: '/admin/settings', icon: Settings },
@@ -76,9 +79,36 @@ export function AppLayout({ children }: AppLayoutProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
const [isCollaborator, setIsCollaborator] = useState(false);
const navItems = isAdmin ? adminNavItems : userNavItems; useEffect(() => {
const mobileNav = isAdmin ? mobileAdminNav : mobileUserNav; const checkCollaborator = async () => {
if (!user || isAdmin) {
setIsCollaborator(false);
return;
}
const [walletRes, productRes] = await Promise.all([
supabase.from("collaborator_wallets").select("user_id").eq("user_id", user.id).maybeSingle(),
supabase.from("products").select("id").eq("collaborator_user_id", user.id).limit(1),
]);
setIsCollaborator(!!walletRes.data || !!(productRes.data && productRes.data.length > 0));
};
checkCollaborator();
}, [user, isAdmin]);
const navItems = isAdmin
? adminNavItems
: isCollaborator
? [...userNavItems.slice(0, 4), { label: 'Profit', href: '/profit', icon: Wallet }, userNavItems[4]]
: userNavItems;
const mobileNav = isAdmin
? mobileAdminNav
: isCollaborator
? [...mobileUserNav.slice(0, 3), { label: 'Profit', href: '/profit', icon: Wallet }, mobileUserNav[3]]
: mobileUserNav;
const handleSignOut = async () => { const handleSignOut = async () => {
await signOut(); await signOut();

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
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 { Switch } from "@/components/ui/switch";
import { toast } from "@/hooks/use-toast";
interface CollaborationSettings {
id?: string;
collaboration_enabled: boolean;
min_withdrawal_amount: number;
default_profit_share: number;
max_pending_withdrawals: number;
withdrawal_processing_days: number;
}
const defaults: CollaborationSettings = {
collaboration_enabled: true,
min_withdrawal_amount: 100000,
default_profit_share: 50,
max_pending_withdrawals: 1,
withdrawal_processing_days: 3,
};
export function CollaborationTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<CollaborationSettings>(defaults);
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
const { data } = await supabase
.from("platform_settings")
.select("id, collaboration_enabled, min_withdrawal_amount, default_profit_share, max_pending_withdrawals, withdrawal_processing_days")
.single();
if (data) {
setSettings({
id: data.id,
collaboration_enabled: data.collaboration_enabled ?? defaults.collaboration_enabled,
min_withdrawal_amount: data.min_withdrawal_amount ?? defaults.min_withdrawal_amount,
default_profit_share: data.default_profit_share ?? defaults.default_profit_share,
max_pending_withdrawals: data.max_pending_withdrawals ?? defaults.max_pending_withdrawals,
withdrawal_processing_days: data.withdrawal_processing_days ?? defaults.withdrawal_processing_days,
});
}
setLoading(false);
};
const save = async () => {
if (!settings.id) {
toast({ title: "Error", description: "platform_settings row not found", variant: "destructive" });
return;
}
setSaving(true);
const payload = {
collaboration_enabled: settings.collaboration_enabled,
min_withdrawal_amount: settings.min_withdrawal_amount,
default_profit_share: settings.default_profit_share,
max_pending_withdrawals: settings.max_pending_withdrawals,
withdrawal_processing_days: settings.withdrawal_processing_days,
};
const { error } = await supabase.from("platform_settings").update(payload).eq("id", settings.id);
if (error) {
toast({ title: "Error", description: error.message, variant: "destructive" });
} else {
toast({ title: "Berhasil", description: "Pengaturan kolaborasi disimpan" });
}
setSaving(false);
};
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
return (
<Card className="border-2 border-border">
<CardHeader>
<CardTitle>Kolaborasi</CardTitle>
<CardDescription>Kontrol global fitur kolaborasi dan withdrawal</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-muted p-4">
<div>
<p className="font-medium">Aktifkan fitur kolaborasi</p>
<p className="text-sm text-muted-foreground">Jika nonaktif, alur profit sharing dimatikan</p>
</div>
<Switch
checked={settings.collaboration_enabled}
onCheckedChange={(checked) => setSettings({ ...settings, collaboration_enabled: checked })}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Minimum Withdrawal (IDR)</Label>
<Input type="number" value={settings.min_withdrawal_amount} onChange={(e) => setSettings({ ...settings, min_withdrawal_amount: parseInt(e.target.value || "0", 10) || 0 })} />
</div>
<div className="space-y-2">
<Label>Default Profit Share (%)</Label>
<Input type="number" min={0} max={100} value={settings.default_profit_share} onChange={(e) => setSettings({ ...settings, default_profit_share: Math.max(0, Math.min(100, parseInt(e.target.value || "0", 10) || 0)) })} />
</div>
<div className="space-y-2">
<Label>Max Pending Withdrawals</Label>
<Input type="number" min={1} value={settings.max_pending_withdrawals} onChange={(e) => setSettings({ ...settings, max_pending_withdrawals: Math.max(1, parseInt(e.target.value || "1", 10) || 1) })} />
</div>
<div className="space-y-2">
<Label>Withdrawal Processing Days</Label>
<Input type="number" min={1} value={settings.withdrawal_processing_days} onChange={(e) => setSettings({ ...settings, withdrawal_processing_days: Math.max(1, parseInt(e.target.value || "1", 10) || 1) })} />
</div>
</div>
<Button onClick={save} disabled={saving}>{saving ? "Menyimpan..." : "Simpan Pengaturan"}</Button>
</CardContent>
</Card>
);
}

View File

@@ -155,19 +155,24 @@ export default function AdminMembers() {
// Step 5: Delete video_progress // Step 5: Delete video_progress
await supabase.from("video_progress").delete().eq("user_id", userId); await supabase.from("video_progress").delete().eq("user_id", userId);
// Step 6: Delete consulting_slots // Step 6: Delete collaboration withdrawals + wallet records
await supabase.from("withdrawals").delete().eq("user_id", userId);
await supabase.from("wallet_transactions").delete().eq("user_id", userId);
await supabase.from("collaborator_wallets").delete().eq("user_id", userId);
// Step 7: Delete consulting_slots
await supabase.from("consulting_slots").delete().eq("user_id", userId); await supabase.from("consulting_slots").delete().eq("user_id", userId);
// Step 7: Delete calendar_events // Step 8: Delete calendar_events
await supabase.from("calendar_events").delete().eq("user_id", userId); await supabase.from("calendar_events").delete().eq("user_id", userId);
// Step 8: Delete user_roles // Step 9: Delete user_roles
await supabase.from("user_roles").delete().eq("user_id", userId); await supabase.from("user_roles").delete().eq("user_id", userId);
// Step 9: Delete profile // Step 10: Delete profile
await supabase.from("profiles").delete().eq("id", userId); await supabase.from("profiles").delete().eq("id", userId);
// Step 10: Delete from auth.users using edge function // Step 11: Delete from auth.users using edge function
const { error: deleteError } = await supabase.functions.invoke('delete-user', { const { error: deleteError } = await supabase.functions.invoke('delete-user', {
body: { user_id: userId } body: { user_id: userId }
}); });
@@ -185,11 +190,12 @@ export default function AdminMembers() {
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setMemberToDelete(null); setMemberToDelete(null);
fetchMembers(); fetchMembers();
} catch (error: any) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : "Gagal menghapus member";
console.error('Delete member error:', error); console.error('Delete member error:', error);
toast({ toast({
title: "Error", title: "Error",
description: error.message || "Gagal menghapus member", description: message,
variant: "destructive" variant: "destructive"
}); });
} finally { } finally {

View File

@@ -45,6 +45,15 @@ interface Product {
sale_price: number | null; sale_price: number | null;
is_active: boolean; is_active: boolean;
chapters?: VideoChapter[]; chapters?: VideoChapter[];
collaborator_user_id?: string | null;
profit_share_percentage?: number;
auto_grant_access?: boolean;
}
interface CollaboratorProfile {
id: string;
name?: string | null;
email?: string | null;
} }
const emptyProduct = { const emptyProduct = {
@@ -64,6 +73,9 @@ const emptyProduct = {
sale_price: null as number | null, sale_price: null as number | null,
is_active: true, is_active: true,
chapters: [] as VideoChapter[], chapters: [] as VideoChapter[],
collaborator_user_id: '',
profit_share_percentage: 50,
auto_grant_access: true,
}; };
export default function AdminProducts() { export default function AdminProducts() {
@@ -78,22 +90,35 @@ export default function AdminProducts() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<string>('all'); const [filterType, setFilterType] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all'); const [filterStatus, setFilterStatus] = useState<string>('all');
const [collaborators, setCollaborators] = useState<CollaboratorProfile[]>([]);
useEffect(() => { useEffect(() => {
if (user && isAdmin) { if (user && isAdmin) {
fetchProducts(); fetchProducts();
fetchCollaborators();
} }
}, [user, isAdmin]); }, [user, isAdmin]);
const fetchProducts = async () => { const fetchProducts = async () => {
const { data, error } = await supabase const { data, error } = await supabase
.from('products') .from('products')
.select('id, title, slug, type, description, content, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, duration_minutes, price, sale_price, is_active, chapters') .select('id, title, slug, type, description, content, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, duration_minutes, price, sale_price, is_active, chapters, collaborator_user_id, profit_share_percentage, auto_grant_access')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (!error && data) setProducts(data); if (!error && data) setProducts(data);
setLoading(false); setLoading(false);
}; };
const fetchCollaborators = async () => {
const { data, error } = await supabase
.from('profiles')
.select('id, name, email')
.order('created_at', { ascending: false });
if (!error && data) {
setCollaborators(data);
}
};
// Filter products based on search and filters // Filter products based on search and filters
const filteredProducts = products.filter((product) => { const filteredProducts = products.filter((product) => {
const matchesSearch = product.title.toLowerCase().includes(searchQuery.toLowerCase()) || const matchesSearch = product.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -135,6 +160,9 @@ export default function AdminProducts() {
sale_price: product.sale_price, sale_price: product.sale_price,
is_active: product.is_active, is_active: product.is_active,
chapters: product.chapters || [], chapters: product.chapters || [],
collaborator_user_id: product.collaborator_user_id || '',
profit_share_percentage: product.profit_share_percentage ?? 50,
auto_grant_access: product.auto_grant_access ?? true,
}); });
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -168,16 +196,79 @@ export default function AdminProducts() {
sale_price: form.sale_price || null, sale_price: form.sale_price || null,
is_active: form.is_active, is_active: form.is_active,
chapters: form.chapters || [], chapters: form.chapters || [],
collaborator_user_id: form.collaborator_user_id || null,
profit_share_percentage: form.collaborator_user_id ? (form.profit_share_percentage || 0) : 0,
auto_grant_access: form.collaborator_user_id ? !!form.auto_grant_access : true,
}; };
if (editingProduct) { if (editingProduct) {
const { error } = await supabase.from('products').update(productData).eq('id', editingProduct.id); const { error } = await supabase.from('products').update(productData).eq('id', editingProduct.id);
if (error) toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' }); if (error) {
else { toast({ title: 'Berhasil', description: 'Produk diupdate' }); setDialogOpen(false); fetchProducts(); } toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' });
} else {
const prevCollaboratorId = editingProduct.collaborator_user_id || null;
const nextCollaboratorId = productData.collaborator_user_id;
// Remove old collaborator access when collaborator changed or auto-grant disabled
if (prevCollaboratorId && (prevCollaboratorId !== nextCollaboratorId || !productData.auto_grant_access)) {
await supabase
.from('user_access')
.delete()
.eq('user_id', prevCollaboratorId)
.eq('product_id', editingProduct.id)
.eq('access_type', 'collaborator');
}
// Grant collaborator access immediately on assignment (no buyer order needed)
if (nextCollaboratorId && productData.auto_grant_access) {
const { error: accessError } = await supabase
.from('user_access')
.upsert({
user_id: nextCollaboratorId,
product_id: editingProduct.id,
access_type: 'collaborator',
granted_by: user?.id || null,
}, { onConflict: 'user_id,product_id' });
if (accessError) {
toast({ title: 'Warning', description: `Produk tersimpan, tapi grant akses kolaborator gagal: ${accessError.message}`, variant: 'destructive' });
}
}
toast({ title: 'Berhasil', description: 'Produk diupdate' });
setDialogOpen(false);
fetchProducts();
}
} else { } else {
const { error } = await supabase.from('products').insert(productData); const { data: created, error } = await supabase
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' }); .from('products')
else { toast({ title: 'Berhasil', description: 'Produk dibuat' }); setDialogOpen(false); fetchProducts(); } .insert(productData)
.select('id')
.single();
if (error || !created) {
toast({ title: 'Error', description: error?.message || 'Gagal membuat produk', variant: 'destructive' });
} else {
// Grant collaborator access immediately on assignment (no buyer order needed)
if (productData.collaborator_user_id && productData.auto_grant_access) {
const { error: accessError } = await supabase
.from('user_access')
.upsert({
user_id: productData.collaborator_user_id,
product_id: created.id,
access_type: 'collaborator',
granted_by: user?.id || null,
}, { onConflict: 'user_id,product_id' });
if (accessError) {
toast({ title: 'Warning', description: `Produk dibuat, tapi grant akses kolaborator gagal: ${accessError.message}`, variant: 'destructive' });
}
}
toast({ title: 'Berhasil', description: 'Produk dibuat' });
setDialogOpen(false);
fetchProducts();
}
} }
setSaving(false); setSaving(false);
}; };
@@ -462,6 +553,67 @@ export default function AdminProducts() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{form.type === 'webinar' && (
<div className="space-y-4 border-2 border-border rounded-lg p-4">
<div className="space-y-2">
<Label>Kolaborator (opsional)</Label>
<Select
value={form.collaborator_user_id || '__none__'}
onValueChange={(value) => setForm({ ...form, collaborator_user_id: value === '__none__' ? '' : value })}
>
<SelectTrigger className="border-2">
<SelectValue placeholder="Pilih kolaborator" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Tanpa kolaborator (solo)</SelectItem>
{collaborators.map((c) => (
<SelectItem key={c.id} value={c.id}>
{(c.name || 'User') + (c.email ? ` (${c.email})` : '')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!!form.collaborator_user_id && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Profit Share Kolaborator (%)</Label>
<Input
type="number"
min={0}
max={100}
value={form.profit_share_percentage}
onChange={(e) => {
const value = parseInt(e.target.value || '0', 10);
const clamped = Math.max(0, Math.min(100, Number.isNaN(value) ? 0 : value));
setForm({ ...form, profit_share_percentage: clamped });
}}
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Host Share (%)</Label>
<Input
value={100 - (form.profit_share_percentage || 0)}
disabled
className="border-2"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={!!form.auto_grant_access}
onCheckedChange={(checked) => setForm({ ...form, auto_grant_access: checked })}
/>
<Label>Auto grant access ke kolaborator</Label>
</div>
</>
)}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label>Deskripsi</Label> <Label>Deskripsi</Label>
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} /> <RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />

View File

@@ -9,7 +9,8 @@ import { NotifikasiTab } from '@/components/admin/settings/NotifikasiTab';
import { KonsultasiTab } from '@/components/admin/settings/KonsultasiTab'; import { KonsultasiTab } from '@/components/admin/settings/KonsultasiTab';
import { BrandingTab } from '@/components/admin/settings/BrandingTab'; import { BrandingTab } from '@/components/admin/settings/BrandingTab';
import { IntegrasiTab } from '@/components/admin/settings/IntegrasiTab'; import { IntegrasiTab } from '@/components/admin/settings/IntegrasiTab';
import { Clock, Bell, Video, Palette, Puzzle } from 'lucide-react'; import { CollaborationTab } from '@/components/admin/settings/CollaborationTab';
import { Clock, Bell, Video, Palette, Puzzle, Wallet } from 'lucide-react';
export default function AdminSettings() { export default function AdminSettings() {
const { user, isAdmin, loading: authLoading } = useAuth(); const { user, isAdmin, loading: authLoading } = useAuth();
@@ -34,7 +35,7 @@ export default function AdminSettings() {
<p className="text-muted-foreground mb-8">Konfigurasi platform</p> <p className="text-muted-foreground mb-8">Konfigurasi platform</p>
<Tabs defaultValue="workhours" className="space-y-6"> <Tabs defaultValue="workhours" className="space-y-6">
<TabsList className="grid w-full grid-cols-5 lg:w-auto lg:inline-flex"> <TabsList className="grid w-full grid-cols-3 md:grid-cols-6 lg:w-auto lg:inline-flex">
<TabsTrigger value="workhours" className="flex items-center gap-2"> <TabsTrigger value="workhours" className="flex items-center gap-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span className="hidden sm:inline">Jam Kerja</span> <span className="hidden sm:inline">Jam Kerja</span>
@@ -55,6 +56,10 @@ export default function AdminSettings() {
<Puzzle className="w-4 h-4" /> <Puzzle className="w-4 h-4" />
<span className="hidden sm:inline">Integrasi</span> <span className="hidden sm:inline">Integrasi</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="collaboration" className="flex items-center gap-2">
<Wallet className="w-4 h-4" />
<span className="hidden sm:inline">Kolaborasi</span>
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="workhours"> <TabsContent value="workhours">
@@ -76,6 +81,10 @@ export default function AdminSettings() {
<TabsContent value="integrasi"> <TabsContent value="integrasi">
<IntegrasiTab /> <IntegrasiTab />
</TabsContent> </TabsContent>
<TabsContent value="collaboration">
<CollaborationTab />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
</AppLayout> </AppLayout>

View File

@@ -0,0 +1,217 @@
import { useEffect, useMemo, useState } from "react";
import { AppLayout } from "@/components/AppLayout";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
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 { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/hooks/use-toast";
import { formatDateTime, formatIDR } from "@/lib/format";
interface Withdrawal {
id: string;
user_id: string;
amount: number;
status: "pending" | "processing" | "completed" | "rejected" | "failed";
requested_at: string;
processed_at: string | null;
payment_reference: string | null;
notes: string | null;
admin_notes: string | null;
profile?: {
name: string | null;
email: string | null;
bank_name: string | null;
bank_account_name: string | null;
bank_account_number: string | null;
} | null;
}
export default function AdminWithdrawals() {
const { user, isAdmin, loading: authLoading } = useAuth();
const [loading, setLoading] = useState(true);
const [rows, setRows] = useState<Withdrawal[]>([]);
const [selected, setSelected] = useState<Withdrawal | null>(null);
const [open, setOpen] = useState(false);
const [action, setAction] = useState<"completed" | "rejected">("completed");
const [paymentReference, setPaymentReference] = useState("");
const [adminNotes, setAdminNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (user && isAdmin) fetchData();
}, [user, isAdmin]);
const fetchData = async () => {
setLoading(true);
const { data, error } = await supabase
.from("withdrawals")
.select(`
id, user_id, amount, status, requested_at, processed_at, payment_reference, notes, admin_notes,
profile:profiles!withdrawals_user_id_fkey (name, email, bank_name, bank_account_name, bank_account_number)
`)
.order("requested_at", { ascending: false });
if (error) {
toast({ title: "Error", description: error.message, variant: "destructive" });
} else {
setRows((data || []) as Withdrawal[]);
}
setLoading(false);
};
const pendingCount = useMemo(() => rows.filter((r) => r.status === "pending").length, [rows]);
const openProcessDialog = (row: Withdrawal, mode: "completed" | "rejected") => {
setSelected(row);
setAction(mode);
setPaymentReference("");
setAdminNotes("");
setOpen(true);
};
const processWithdrawal = async () => {
if (!selected) return;
if (action === "completed" && !paymentReference.trim()) {
toast({ title: "Payment reference wajib diisi", variant: "destructive" });
return;
}
setSubmitting(true);
const { data, error } = await supabase.functions.invoke("process-withdrawal", {
body: {
withdrawalId: selected.id,
status: action,
payment_reference: paymentReference || null,
admin_notes: adminNotes || null,
reason: adminNotes || null,
},
});
const response = data as { error?: string } | null;
if (error || response?.error) {
toast({
title: "Gagal memproses withdrawal",
description: response?.error || error?.message || "Unknown error",
variant: "destructive",
});
setSubmitting(false);
return;
}
toast({ title: "Berhasil", description: "Withdrawal berhasil diproses" });
setSubmitting(false);
setOpen(false);
setSelected(null);
fetchData();
};
if (authLoading || loading) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<Skeleton className="h-72 w-full" />
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 space-y-6">
<div>
<h1 className="text-4xl font-bold mb-2">Withdrawal Requests</h1>
<p className="text-muted-foreground">Kelola permintaan pencairan kolaborator</p>
</div>
<Card className="border-2 border-border">
<CardHeader><CardTitle>Pending: {pendingCount}</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Collaborator</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Bank</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{formatDateTime(row.requested_at)}</TableCell>
<TableCell>
<div className="text-sm">
<p className="font-medium">{row.profile?.name || "-"}</p>
<p className="text-muted-foreground">{row.profile?.email || "-"}</p>
</div>
</TableCell>
<TableCell>{formatIDR(row.amount || 0)}</TableCell>
<TableCell>
<Badge variant={row.status === "completed" ? "default" : row.status === "rejected" ? "destructive" : "secondary"}>
{row.status}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm">
<p>{row.profile?.bank_name || "-"}</p>
<p className="text-muted-foreground">{row.profile?.bank_account_number || "-"}</p>
</div>
</TableCell>
<TableCell className="text-right space-x-2">
{row.status === "pending" ? (
<>
<Button size="sm" onClick={() => openProcessDialog(row, "completed")}>Approve</Button>
<Button size="sm" variant="destructive" onClick={() => openProcessDialog(row, "rejected")}>Reject</Button>
</>
) : (
<span className="text-muted-foreground text-sm">Processed</span>
)}
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Belum ada withdrawal request
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="border-2 border-border">
<DialogHeader><DialogTitle>{action === "completed" ? "Approve" : "Reject"} Withdrawal</DialogTitle></DialogHeader>
<div className="space-y-4">
{action === "completed" && (
<div className="space-y-2">
<Label>Payment Reference</Label>
<Input value={paymentReference} onChange={(e) => setPaymentReference(e.target.value)} />
</div>
)}
<div className="space-y-2">
<Label>Admin Notes</Label>
<Textarea value={adminNotes} onChange={(e) => setAdminNotes(e.target.value)} />
</div>
<Button onClick={processWithdrawal} disabled={submitting} className="w-full">
{submitting ? "Processing..." : "Submit"}
</Button>
</div>
</DialogContent>
</Dialog>
</AppLayout>
);
}

View File

@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { formatIDR } from "@/lib/format"; import { formatIDR } from "@/lib/format";
import { Video, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react"; import { Video, ArrowRight, Package, Receipt, ShoppingBag, Wallet } from "lucide-react";
import { WhatsAppBanner } from "@/components/WhatsAppBanner"; import { WhatsAppBanner } from "@/components/WhatsAppBanner";
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory"; import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert"; import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert";
@@ -58,6 +58,7 @@ export default function MemberDashboard() {
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]); const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasWhatsApp, setHasWhatsApp] = useState(true); const [hasWhatsApp, setHasWhatsApp] = useState(true);
const [isCollaborator, setIsCollaborator] = useState(false);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) navigate("/auth"); if (!authLoading && !user) navigate("/auth");
@@ -122,7 +123,7 @@ export default function MemberDashboard() {
}, []); }, []);
const fetchData = async () => { const fetchData = async () => {
const [accessRes, ordersRes, paidOrdersRes, profileRes, slotsRes] = await Promise.all([ const [accessRes, ordersRes, paidOrdersRes, profileRes, slotsRes, walletRes, collaboratorProductRes] = await Promise.all([
supabase supabase
.from("user_access") .from("user_access")
.select(`id, product:products (id, title, slug, type, meeting_link, recording_url, event_start, duration_minutes)`) .select(`id, product:products (id, title, slug, type, meeting_link, recording_url, event_start, duration_minutes)`)
@@ -148,6 +149,8 @@ export default function MemberDashboard() {
.eq("user_id", user!.id) .eq("user_id", user!.id)
.eq("status", "confirmed") .eq("status", "confirmed")
.order("date", { ascending: false }), .order("date", { ascending: false }),
supabase.from("collaborator_wallets").select("user_id").eq("user_id", user!.id).maybeSingle(),
supabase.from("products").select("id").eq("collaborator_user_id", user!.id).limit(1),
]); ]);
// Combine access from user_access and paid orders // Combine access from user_access and paid orders
@@ -170,6 +173,7 @@ export default function MemberDashboard() {
if (ordersRes.data) setRecentOrders(ordersRes.data); if (ordersRes.data) setRecentOrders(ordersRes.data);
if (profileRes.data) setHasWhatsApp(!!profileRes.data.whatsapp_number); if (profileRes.data) setHasWhatsApp(!!profileRes.data.whatsapp_number);
if (slotsRes.data) setConsultingSlots(slotsRes.data as unknown as ConsultingSlot[]); if (slotsRes.data) setConsultingSlots(slotsRes.data as unknown as ConsultingSlot[]);
setIsCollaborator(!!walletRes?.data || !!(collaboratorProductRes?.data && collaboratorProductRes.data.length > 0));
setLoading(false); setLoading(false);
}; };
@@ -282,6 +286,22 @@ export default function MemberDashboard() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{isCollaborator && (
<Card className="border-2 border-border col-span-full">
<CardContent className="pt-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Wallet className="w-10 h-10 text-primary" />
<div>
<p className="text-sm text-muted-foreground">Kolaborator Dashboard</p>
<p className="font-medium">Lihat profit & withdrawal</p>
</div>
</div>
<Button variant="outline" onClick={() => navigate("/profit")} className="border-2">
Buka Profit
</Button>
</CardContent>
</Card>
)}
</div> </div>
{access.length > 0 && ( {access.length > 0 && (

View File

@@ -19,6 +19,11 @@ interface Profile {
avatar_url: string | null; avatar_url: string | null;
whatsapp_number: string | null; whatsapp_number: string | null;
whatsapp_opt_in: boolean; whatsapp_opt_in: boolean;
bio?: string | null;
portfolio_url?: string | null;
bank_account_number?: string | null;
bank_account_name?: string | null;
bank_name?: string | null;
} }
export default function MemberProfile() { export default function MemberProfile() {
@@ -32,6 +37,11 @@ export default function MemberProfile() {
avatar_url: '', avatar_url: '',
whatsapp_number: '', whatsapp_number: '',
whatsapp_opt_in: false, whatsapp_opt_in: false,
bio: '',
portfolio_url: '',
bank_name: '',
bank_account_name: '',
bank_account_number: '',
}); });
useEffect(() => { useEffect(() => {
@@ -51,6 +61,11 @@ export default function MemberProfile() {
avatar_url: data.avatar_url || '', avatar_url: data.avatar_url || '',
whatsapp_number: data.whatsapp_number || '', whatsapp_number: data.whatsapp_number || '',
whatsapp_opt_in: data.whatsapp_opt_in || false, whatsapp_opt_in: data.whatsapp_opt_in || false,
bio: data.bio || '',
portfolio_url: data.portfolio_url || '',
bank_name: data.bank_name || '',
bank_account_name: data.bank_account_name || '',
bank_account_number: data.bank_account_number || '',
}); });
} }
setLoading(false); setLoading(false);
@@ -81,6 +96,11 @@ export default function MemberProfile() {
avatar_url: form.avatar_url || null, avatar_url: form.avatar_url || null,
whatsapp_number: normalizedWA || null, whatsapp_number: normalizedWA || null,
whatsapp_opt_in: form.whatsapp_opt_in, whatsapp_opt_in: form.whatsapp_opt_in,
bio: form.bio || null,
portfolio_url: form.portfolio_url || null,
bank_name: form.bank_name || null,
bank_account_name: form.bank_account_name || null,
bank_account_number: form.bank_account_number || null,
}) })
.eq('id', user!.id); .eq('id', user!.id);
@@ -146,6 +166,24 @@ export default function MemberProfile() {
placeholder="https://..." placeholder="https://..."
/> />
</div> </div>
<div className="space-y-2">
<Label>Bio</Label>
<Input
value={form.bio}
onChange={(e) => setForm({ ...form, bio: e.target.value })}
className="border-2"
placeholder="Tentang Anda"
/>
</div>
<div className="space-y-2">
<Label>Portfolio URL</Label>
<Input
value={form.portfolio_url}
onChange={(e) => setForm({ ...form, portfolio_url: e.target.value })}
className="border-2"
placeholder="https://..."
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -184,6 +222,41 @@ export default function MemberProfile() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle>Informasi Bank (Untuk Withdrawal)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Nama Bank</Label>
<Input
value={form.bank_name}
onChange={(e) => setForm({ ...form, bank_name: e.target.value })}
className="border-2"
placeholder="BCA / Mandiri / BNI / dll"
/>
</div>
<div className="space-y-2">
<Label>Nama Pemilik Rekening</Label>
<Input
value={form.bank_account_name}
onChange={(e) => setForm({ ...form, bank_account_name: e.target.value })}
className="border-2"
placeholder="Nama sesuai rekening"
/>
</div>
<div className="space-y-2">
<Label>Nomor Rekening</Label>
<Input
value={form.bank_account_number}
onChange={(e) => setForm({ ...form, bank_account_number: e.target.value })}
className="border-2"
placeholder="Nomor rekening"
/>
</div>
</CardContent>
</Card>
<Button onClick={handleSave} disabled={saving} className="w-full shadow-sm"> <Button onClick={handleSave} disabled={saving} className="w-full shadow-sm">
{saving ? 'Menyimpan...' : 'Simpan Profil'} {saving ? 'Menyimpan...' : 'Simpan Profil'}
</Button> </Button>

View File

@@ -0,0 +1,239 @@
import { useEffect, useMemo, 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 { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/hooks/use-toast";
import { formatIDR, formatDateTime } from "@/lib/format";
interface WalletData {
current_balance: number;
total_earned: number;
total_withdrawn: number;
pending_balance: number;
}
interface ProfitRow {
order_item_id: string;
order_id: string;
created_at: string;
product_title: string;
profit_share_percentage: number;
profit_amount: number;
profit_status: string | null;
wallet_transaction_id: string | null;
}
interface WithdrawalRow {
id: string;
amount: number;
status: string;
requested_at: string;
processed_at: string | null;
payment_reference: string | null;
admin_notes: string | null;
}
export default function MemberProfit() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [wallet, setWallet] = useState<WalletData | null>(null);
const [profits, setProfits] = useState<ProfitRow[]>([]);
const [withdrawals, setWithdrawals] = useState<WithdrawalRow[]>([]);
const [openWithdrawDialog, setOpenWithdrawDialog] = useState(false);
const [withdrawAmount, setWithdrawAmount] = useState("");
const [withdrawNotes, setWithdrawNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [settings, setSettings] = useState<{ min_withdrawal_amount: number } | null>(null);
useEffect(() => {
if (!authLoading && !user) navigate("/auth");
if (user) fetchData();
}, [user, authLoading]);
const fetchData = async () => {
const [walletRes, profitRes, withdrawalRes, settingsRes] = await Promise.all([
supabase.rpc("get_collaborator_wallet", { p_user_id: user!.id }),
supabase
.from("collaborator_profits")
.select("*")
.eq("collaborator_user_id", user!.id)
.order("created_at", { ascending: false }),
supabase
.from("withdrawals")
.select("id, amount, status, requested_at, processed_at, payment_reference, admin_notes")
.eq("user_id", user!.id)
.order("requested_at", { ascending: false }),
supabase.rpc("get_collaboration_settings"),
]);
setWallet((walletRes.data?.[0] as WalletData) || {
current_balance: 0,
total_earned: 0,
total_withdrawn: 0,
pending_balance: 0,
});
setProfits((profitRes.data as ProfitRow[]) || []);
setWithdrawals((withdrawalRes.data as WithdrawalRow[]) || []);
setSettings({ min_withdrawal_amount: settingsRes.data?.[0]?.min_withdrawal_amount || 100000 });
setLoading(false);
};
const canSubmit = useMemo(() => {
const amount = Number(withdrawAmount || 0);
const min = settings?.min_withdrawal_amount || 100000;
const available = Number(wallet?.current_balance || 0);
return amount >= min && amount <= available;
}, [withdrawAmount, settings, wallet]);
const submitWithdrawal = async () => {
if (!canSubmit) {
toast({
title: "Nominal tidak valid",
description: "Periksa minimum penarikan dan saldo tersedia",
variant: "destructive",
});
return;
}
setSubmitting(true);
const { data, error } = await supabase.functions.invoke("create-withdrawal", {
body: {
amount: Number(withdrawAmount),
notes: withdrawNotes || null,
},
});
const response = data as { error?: string } | null;
if (error || response?.error) {
toast({
title: "Gagal membuat withdrawal",
description: response?.error || error?.message || "Unknown error",
variant: "destructive",
});
setSubmitting(false);
return;
}
toast({ title: "Berhasil", description: "Withdrawal request berhasil dibuat" });
setSubmitting(false);
setOpenWithdrawDialog(false);
setWithdrawAmount("");
setWithdrawNotes("");
fetchData();
};
if (authLoading || loading) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<Skeleton className="h-72 w-full" />
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 space-y-6">
<div>
<h1 className="text-4xl font-bold mb-2">Profit</h1>
<p className="text-muted-foreground">Ringkasan pendapatan kolaborasi Anda</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="border-2 border-border"><CardContent className="pt-6"><p className="text-sm text-muted-foreground">Total Earnings</p><p className="text-2xl font-bold">{formatIDR(wallet?.total_earned || 0)}</p></CardContent></Card>
<Card className="border-2 border-border"><CardContent className="pt-6"><p className="text-sm text-muted-foreground">Available Balance</p><p className="text-2xl font-bold">{formatIDR(wallet?.current_balance || 0)}</p></CardContent></Card>
<Card className="border-2 border-border"><CardContent className="pt-6"><p className="text-sm text-muted-foreground">Total Withdrawn</p><p className="text-2xl font-bold">{formatIDR(wallet?.total_withdrawn || 0)}</p></CardContent></Card>
<Card className="border-2 border-border"><CardContent className="pt-6"><p className="text-sm text-muted-foreground">Pending Balance</p><p className="text-2xl font-bold">{formatIDR(wallet?.pending_balance || 0)}</p></CardContent></Card>
</div>
<Card className="border-2 border-border">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Withdrawal</CardTitle>
<Button onClick={() => setOpenWithdrawDialog(true)}>Request Withdrawal</Button>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>Minimum withdrawal: {formatIDR(settings?.min_withdrawal_amount || 100000)}</p>
<p>Available balance: {formatIDR(wallet?.current_balance || 0)}</p>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardHeader><CardTitle>Profit History</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader><TableRow><TableHead>Date</TableHead><TableHead>Product</TableHead><TableHead>Share</TableHead><TableHead>Amount</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{profits.map((row) => (
<TableRow key={row.order_item_id}>
<TableCell>{formatDateTime(row.created_at)}</TableCell>
<TableCell>{row.product_title}</TableCell>
<TableCell>{row.profit_share_percentage}%</TableCell>
<TableCell>{formatIDR(row.profit_amount || 0)}</TableCell>
<TableCell><Badge variant="secondary">{row.profit_status || "-"}</Badge></TableCell>
</TableRow>
))}
{profits.length === 0 && (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">Belum ada data profit</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardHeader><CardTitle>Withdrawal History</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader><TableRow><TableHead>Date</TableHead><TableHead>Amount</TableHead><TableHead>Status</TableHead><TableHead>Reference</TableHead></TableRow></TableHeader>
<TableBody>
{withdrawals.map((w) => (
<TableRow key={w.id}>
<TableCell>{formatDateTime(w.requested_at)}</TableCell>
<TableCell>{formatIDR(w.amount || 0)}</TableCell>
<TableCell><Badge variant={w.status === "completed" ? "default" : w.status === "rejected" ? "destructive" : "secondary"}>{w.status}</Badge></TableCell>
<TableCell>{w.payment_reference || "-"}</TableCell>
</TableRow>
))}
{withdrawals.length === 0 && (
<TableRow><TableCell colSpan={4} className="text-center text-muted-foreground py-8">Belum ada withdrawal</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Dialog open={openWithdrawDialog} onOpenChange={setOpenWithdrawDialog}>
<DialogContent className="border-2 border-border">
<DialogHeader><DialogTitle>Request Withdrawal</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Nominal (IDR)</Label>
<Input type="number" value={withdrawAmount} onChange={(e) => setWithdrawAmount(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Textarea value={withdrawNotes} onChange={(e) => setWithdrawNotes(e.target.value)} />
</div>
<Button onClick={submitWithdrawal} disabled={submitting} className="w-full">
{submitting ? "Submitting..." : "Submit Withdrawal"}
</Button>
</div>
</DialogContent>
</Dialog>
</AppLayout>
);
}

View File

@@ -0,0 +1,164 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
},
);
const token = authHeader.replace("Bearer ", "");
const { data: authData } = await supabase.auth.getUser(token);
const user = authData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { amount, notes } = await req.json();
const parsedAmount = Number(amount || 0);
if (parsedAmount <= 0) {
return new Response(
JSON.stringify({ error: "Invalid withdrawal amount" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { data: wallet } = await supabase
.rpc("get_collaborator_wallet", { p_user_id: user.id });
const currentBalance = Number(wallet?.[0]?.current_balance || 0);
const { data: settings } = await supabase.rpc("get_collaboration_settings");
const minWithdrawal = Number(settings?.[0]?.min_withdrawal_amount || 100000);
const maxPendingWithdrawals = Number(settings?.[0]?.max_pending_withdrawals || 1);
if (currentBalance < minWithdrawal) {
return new Response(
JSON.stringify({ error: `Minimum withdrawal is Rp ${minWithdrawal.toLocaleString("id-ID")}` }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (parsedAmount > currentBalance) {
return new Response(
JSON.stringify({ error: "Insufficient available balance", available: currentBalance }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { data: existingPending } = await supabase
.from("withdrawals")
.select("id")
.eq("user_id", user.id)
.eq("status", "pending");
if ((existingPending?.length || 0) >= maxPendingWithdrawals) {
return new Response(
JSON.stringify({ error: `Maximum ${maxPendingWithdrawals} pending withdrawal(s) allowed` }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { data: profile } = await supabase
.from("profiles")
.select("bank_account_name, bank_account_number, bank_name")
.eq("id", user.id)
.maybeSingle();
if (!profile?.bank_account_number || !profile?.bank_account_name || !profile?.bank_name) {
return new Response(
JSON.stringify({ error: "Please complete your bank account information in profile settings" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { data: withdrawal, error: createError } = await supabase
.from("withdrawals")
.insert({
user_id: user.id,
amount: parsedAmount,
status: "pending",
payment_method: "bank_transfer",
payment_reference: `${profile.bank_name} - ${profile.bank_account_number} (${profile.bank_account_name})`,
notes: notes || null,
created_by: user.id,
})
.select()
.single();
if (createError || !withdrawal) {
throw createError || new Error("Failed to create withdrawal");
}
const { data: txId, error: holdError } = await supabase
.rpc("hold_withdrawal_amount", {
p_user_id: user.id,
p_withdrawal_id: withdrawal.id,
p_amount: parsedAmount,
});
if (holdError) {
await supabase.from("withdrawals").delete().eq("id", withdrawal.id);
throw holdError;
}
await supabase
.from("withdrawals")
.update({ wallet_transaction_id: txId })
.eq("id", withdrawal.id);
await supabase.functions.invoke("send-collaboration-notification", {
body: {
type: "withdrawal_requested",
withdrawalId: withdrawal.id,
userId: user.id,
amount: parsedAmount,
bankInfo: {
bankName: profile.bank_name,
accountNumber: profile.bank_account_number,
accountName: profile.bank_account_name,
},
},
});
return new Response(
JSON.stringify({
success: true,
withdrawal: { ...withdrawal, wallet_transaction_id: txId },
}),
{ status: 201, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to create withdrawal";
return new Response(
JSON.stringify({ error: message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});

View File

@@ -0,0 +1,50 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
},
);
const { data: settings, error } = await supabase
.from("platform_settings")
.select("owner_name, owner_avatar_url")
.limit(1)
.maybeSingle();
if (error) {
throw error;
}
return new Response(
JSON.stringify({
owner_name: settings?.owner_name || "Dwindi",
owner_avatar_url: settings?.owner_avatar_url || "",
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to get owner identity";
return new Response(
JSON.stringify({ error: message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});

View File

@@ -38,8 +38,10 @@ serve(async (req: Request): Promise<Response> => {
*, *,
profiles(email, name), profiles(email, name),
order_items ( order_items (
id,
product_id, product_id,
product:products (title, type) unit_price,
product:products (title, type, collaborator_user_id, profit_share_percentage, auto_grant_access)
), ),
consulting_sessions ( consulting_sessions (
id, id,
@@ -80,8 +82,16 @@ serve(async (req: Request): Promise<Response> => {
const userEmail = order.profiles?.email || ""; const userEmail = order.profiles?.email || "";
const userName = order.profiles?.name || userEmail.split('@')[0] || "Pelanggan"; const userName = order.profiles?.name || userEmail.split('@')[0] || "Pelanggan";
const orderItems = order.order_items as Array<{ const orderItems = order.order_items as Array<{
id: string;
product_id: string; product_id: string;
product: { title: string; type: string }; unit_price?: number;
product: {
title: string;
type: string;
collaborator_user_id?: string | null;
profit_share_percentage?: number | null;
auto_grant_access?: boolean | null;
};
}>; }>;
// Check if this is a consulting order by checking consulting_sessions // Check if this is a consulting order by checking consulting_sessions
@@ -218,6 +228,84 @@ serve(async (req: Request): Promise<Response> => {
}); });
console.log("[HANDLE-PAID] Access granted for product:", item.product_id); console.log("[HANDLE-PAID] Access granted for product:", item.product_id);
} }
// Collaboration: credit collaborator wallet if this product has a collaborator
const collaboratorUserId = item.product?.collaborator_user_id;
const profitSharePct = Number(item.product?.profit_share_percentage || 0);
const autoGrantAccess = item.product?.auto_grant_access !== false;
const itemPrice = Number(item.unit_price || 0);
if (collaboratorUserId && profitSharePct > 0 && itemPrice > 0) {
const hostShare = itemPrice * ((100 - profitSharePct) / 100);
const collaboratorShare = itemPrice * (profitSharePct / 100);
// Save profit split to order_items
const { error: splitError } = await supabase
.from("order_items")
.update({
host_share: hostShare,
collaborator_share: collaboratorShare,
})
.eq("id", item.id);
if (splitError) {
console.error("[HANDLE-PAID] Failed to update order item split:", splitError);
continue;
}
// Credit collaborator wallet (also stores wallet_transaction_id on order_items)
const { data: transactionId, error: creditError } = await supabase
.rpc("credit_collaborator_wallet", {
p_user_id: collaboratorUserId,
p_order_item_id: item.id,
p_amount: collaboratorShare,
p_description: `Profit from sale: ${item.product?.title || "Product"}`,
});
if (creditError) {
console.error("[HANDLE-PAID] Failed to credit collaborator wallet:", creditError);
continue;
}
console.log(
`[HANDLE-PAID] Credited collaborator wallet: ${collaboratorUserId} + Rp ${collaboratorShare}, tx=${transactionId}`
);
// Grant collaborator access to the same product if enabled
if (autoGrantAccess) {
const { error: collaboratorAccessError } = await supabase
.from("user_access")
.upsert(
{
user_id: collaboratorUserId,
product_id: item.product_id,
access_type: "collaborator",
granted_by: order.user_id,
},
{ onConflict: "user_id,product_id" }
);
if (collaboratorAccessError) {
console.error("[HANDLE-PAID] Failed to grant collaborator access:", collaboratorAccessError);
}
}
// Notify collaborator about new sale
const { error: collabNotifyError } = await supabase.functions.invoke("send-collaboration-notification", {
body: {
type: "new_sale",
collaboratorUserId,
productTitle: item.product?.title || "Product",
profitAmount: collaboratorShare,
profitSharePercentage: profitSharePct,
saleDate: order.created_at,
},
});
if (collabNotifyError) {
console.error("[HANDLE-PAID] Failed to send collaborator notification:", collabNotifyError);
}
}
} }
const productTitles = orderItems.map(i => i.product.title); const productTitles = orderItems.map(i => i.product.title);
@@ -257,12 +345,13 @@ serve(async (req: Request): Promise<Response> => {
{ headers: { ...corsHeaders, "Content-Type": "application/json" } } { headers: { ...corsHeaders, "Content-Type": "application/json" } }
); );
} catch (error: any) { } catch (error: unknown) {
console.error("[HANDLE-PAID] Error:", error); console.error("[HANDLE-PAID] Error:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, success: false,
error: error.message || "Internal server error" error: message
}), }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
); );
@@ -271,9 +360,9 @@ serve(async (req: Request): Promise<Response> => {
// Helper function to send notification // Helper function to send notification
async function sendNotification( async function sendNotification(
supabase: any, supabase: ReturnType<typeof createClient>,
templateKey: string, templateKey: string,
data: Record<string, any> data: Record<string, unknown>
): Promise<void> { ): Promise<void> {
console.log("[HANDLE-PAID] Sending notification:", templateKey); console.log("[HANDLE-PAID] Sending notification:", templateKey);
@@ -319,8 +408,8 @@ async function sendNotification(
}, },
body: JSON.stringify({ body: JSON.stringify({
template_key: templateKey, template_key: templateKey,
recipient_email: data.email, recipient_email: String(data.email || ""),
recipient_name: data.user_name || data.nama, recipient_name: String((data.user_name as string) || (data.nama as string) || ""),
variables: data, variables: data,
}), }),
}); });

View File

@@ -0,0 +1,154 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
},
);
const token = authHeader.replace("Bearer ", "");
const { data: authData } = await supabase.auth.getUser(token);
const user = authData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { data: isAdmin } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", user.id)
.eq("role", "admin")
.maybeSingle();
if (!isAdmin) {
return new Response(
JSON.stringify({ error: "Forbidden - Admin only" }),
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { withdrawalId, status, payment_reference, admin_notes, reason } = await req.json();
if (!withdrawalId || !["completed", "rejected"].includes(status)) {
return new Response(
JSON.stringify({ error: "Invalid payload" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const { data: withdrawal } = await supabase
.from("withdrawals")
.select("*, user:profiles(name, email)")
.eq("id", withdrawalId)
.maybeSingle();
if (!withdrawal) {
return new Response(
JSON.stringify({ error: "Withdrawal not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (withdrawal.status !== "pending") {
return new Response(
JSON.stringify({ error: "Withdrawal already processed" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (status === "completed") {
await supabase.rpc("complete_withdrawal", {
p_user_id: withdrawal.user_id,
p_withdrawal_id: withdrawalId,
p_amount: withdrawal.amount,
p_payment_reference: payment_reference || "-",
});
await supabase
.from("withdrawals")
.update({
status: "completed",
processed_at: new Date().toISOString(),
payment_reference: payment_reference || null,
admin_notes: admin_notes || null,
updated_by: user.id,
updated_at: new Date().toISOString(),
})
.eq("id", withdrawalId);
await supabase.functions.invoke("send-collaboration-notification", {
body: {
type: "withdrawal_completed",
userId: withdrawal.user_id,
amount: withdrawal.amount,
paymentReference: payment_reference || "-",
},
});
} else {
await supabase.rpc("reject_withdrawal", {
p_user_id: withdrawal.user_id,
p_withdrawal_id: withdrawalId,
p_amount: withdrawal.amount,
p_reason: reason || "Withdrawal rejected by admin",
});
await supabase
.from("withdrawals")
.update({
status: "rejected",
processed_at: new Date().toISOString(),
admin_notes: admin_notes || reason || null,
updated_by: user.id,
updated_at: new Date().toISOString(),
})
.eq("id", withdrawalId);
await supabase.functions.invoke("send-collaboration-notification", {
body: {
type: "withdrawal_rejected",
userId: withdrawal.user_id,
amount: withdrawal.amount,
reason: admin_notes || reason || "Withdrawal rejected",
},
});
}
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to process withdrawal";
return new Response(
JSON.stringify({ error: message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});

View File

@@ -0,0 +1,172 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface NotificationPayload {
type: "new_sale" | "withdrawal_requested" | "withdrawal_completed" | "withdrawal_rejected";
collaboratorUserId?: string;
userId?: string;
amount?: number;
productTitle?: string;
profitAmount?: number;
profitSharePercentage?: number;
saleDate?: string;
paymentReference?: string;
reason?: string;
bankInfo?: {
bankName: string;
accountNumber: string;
accountName: string;
};
}
async function sendEmail(recipient: string, subject: string, content: string): Promise<void> {
const response = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email-v2`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
},
body: JSON.stringify({
recipient,
subject,
content,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`send-email-v2 failed: ${response.status} ${text}`);
}
}
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
},
);
const data = await req.json() as NotificationPayload;
const { type } = data;
let recipientEmail = "";
let subject = "";
let htmlContent = "";
if (type === "new_sale") {
const { data: collaborator } = await supabase
.from("profiles")
.select("email, name")
.eq("id", data.collaboratorUserId || "")
.maybeSingle();
recipientEmail = collaborator?.email || "";
subject = `🎉 You earned Rp ${(data.profitAmount || 0).toLocaleString("id-ID")} from ${data.productTitle || "your product"}!`;
htmlContent = `
<h2>Great news, ${collaborator?.name || "Partner"}!</h2>
<p>Your collaborative webinar <strong>${data.productTitle || "-"}</strong> just made a sale.</p>
<ul>
<li>Your Share: ${data.profitSharePercentage || 0}%</li>
<li>Profit Earned: <strong>Rp ${(data.profitAmount || 0).toLocaleString("id-ID")}</strong></li>
<li>Sale Date: ${data.saleDate ? new Date(data.saleDate).toLocaleDateString("id-ID") : "-"}</li>
</ul>
`;
} else if (type === "withdrawal_requested") {
const { data: adminRole } = await supabase
.from("user_roles")
.select("user_id")
.eq("role", "admin")
.limit(1)
.maybeSingle();
const { data: admin } = await supabase
.from("profiles")
.select("email")
.eq("id", adminRole?.user_id || "")
.maybeSingle();
recipientEmail = admin?.email || "";
subject = "💸 New Withdrawal Request";
htmlContent = `
<h2>New Withdrawal Request</h2>
<p>A collaborator has requested withdrawal:</p>
<ul>
<li>Amount: <strong>Rp ${(data.amount || 0).toLocaleString("id-ID")}</strong></li>
<li>Bank: ${data.bankInfo?.bankName || "-"}</li>
<li>Account: ${data.bankInfo?.accountNumber || "-"} (${data.bankInfo?.accountName || "-"})</li>
</ul>
`;
} else if (type === "withdrawal_completed") {
const { data: user } = await supabase
.from("profiles")
.select("email, name")
.eq("id", data.userId || "")
.maybeSingle();
recipientEmail = user?.email || "";
subject = `✅ Withdrawal Completed: Rp ${(data.amount || 0).toLocaleString("id-ID")}`;
htmlContent = `
<h2>Withdrawal Completed, ${user?.name || "Partner"}!</h2>
<ul>
<li>Amount: <strong>Rp ${(data.amount || 0).toLocaleString("id-ID")}</strong></li>
<li>Payment Reference: ${data.paymentReference || "-"}</li>
</ul>
`;
} else if (type === "withdrawal_rejected") {
const { data: user } = await supabase
.from("profiles")
.select("email, name")
.eq("id", data.userId || "")
.maybeSingle();
recipientEmail = user?.email || "";
subject = "❌ Withdrawal Request Returned";
htmlContent = `
<h2>Withdrawal Request Returned</h2>
<p>Hi ${user?.name || "Partner"},</p>
<p>Your withdrawal request of <strong>Rp ${(data.amount || 0).toLocaleString("id-ID")}</strong> has been returned to your wallet.</p>
<p>Reason: ${data.reason || "Contact admin for details"}</p>
`;
} else {
return new Response(
JSON.stringify({ error: "Unknown notification type" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (!recipientEmail) {
return new Response(
JSON.stringify({ error: "Recipient email not found" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
await sendEmail(recipientEmail, subject, htmlContent);
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to send notification";
return new Response(
JSON.stringify({ error: message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});