Compare commits
4 Commits
8e64780f72
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b4496dca | ||
|
|
d58f597ba6 | ||
|
|
8be40dc0f9 | ||
|
|
52b16dce07 |
1497
collaborative-webinar-wallet-implementation.md
Normal file
1497
collaborative-webinar-wallet-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
18
src/App.tsx
18
src/App.tsx
@@ -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 />} />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X } from 'lucide-react';
|
import { uploadToContentStorage } from '@/lib/storageUpload';
|
||||||
|
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||||
|
import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X, User } from 'lucide-react';
|
||||||
|
|
||||||
interface HomepageFeature {
|
interface HomepageFeature {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -22,6 +25,8 @@ interface PlatformSettings {
|
|||||||
brand_favicon_url: string;
|
brand_favicon_url: string;
|
||||||
brand_primary_color: string;
|
brand_primary_color: string;
|
||||||
brand_accent_color: string;
|
brand_accent_color: string;
|
||||||
|
owner_name: string;
|
||||||
|
owner_avatar_url: string;
|
||||||
homepage_headline: string;
|
homepage_headline: string;
|
||||||
homepage_description: string;
|
homepage_description: string;
|
||||||
homepage_features: HomepageFeature[];
|
homepage_features: HomepageFeature[];
|
||||||
@@ -40,6 +45,8 @@ const emptySettings: PlatformSettings = {
|
|||||||
brand_favicon_url: '',
|
brand_favicon_url: '',
|
||||||
brand_primary_color: '#111827',
|
brand_primary_color: '#111827',
|
||||||
brand_accent_color: '#0F766E',
|
brand_accent_color: '#0F766E',
|
||||||
|
owner_name: 'Dwindi',
|
||||||
|
owner_avatar_url: '',
|
||||||
homepage_headline: 'Learn. Grow. Succeed.',
|
homepage_headline: 'Learn. Grow. Succeed.',
|
||||||
homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.',
|
homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.',
|
||||||
homepage_features: defaultFeatures,
|
homepage_features: defaultFeatures,
|
||||||
@@ -53,6 +60,7 @@ export function BrandingTab() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
||||||
|
const [uploadingOwnerAvatar, setUploadingOwnerAvatar] = useState(false);
|
||||||
|
|
||||||
// Preview states for selected files
|
// Preview states for selected files
|
||||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||||
@@ -91,6 +99,8 @@ export function BrandingTab() {
|
|||||||
brand_favicon_url: data.brand_favicon_url || '',
|
brand_favicon_url: data.brand_favicon_url || '',
|
||||||
brand_primary_color: data.brand_primary_color || '#111827',
|
brand_primary_color: data.brand_primary_color || '#111827',
|
||||||
brand_accent_color: data.brand_accent_color || '#0F766E',
|
brand_accent_color: data.brand_accent_color || '#0F766E',
|
||||||
|
owner_name: data.owner_name || 'Dwindi',
|
||||||
|
owner_avatar_url: data.owner_avatar_url || '',
|
||||||
homepage_headline: data.homepage_headline || emptySettings.homepage_headline,
|
homepage_headline: data.homepage_headline || emptySettings.homepage_headline,
|
||||||
homepage_description: data.homepage_description || emptySettings.homepage_description,
|
homepage_description: data.homepage_description || emptySettings.homepage_description,
|
||||||
homepage_features: features,
|
homepage_features: features,
|
||||||
@@ -109,6 +119,8 @@ export function BrandingTab() {
|
|||||||
brand_favicon_url: settings.brand_favicon_url,
|
brand_favicon_url: settings.brand_favicon_url,
|
||||||
brand_primary_color: settings.brand_primary_color,
|
brand_primary_color: settings.brand_primary_color,
|
||||||
brand_accent_color: settings.brand_accent_color,
|
brand_accent_color: settings.brand_accent_color,
|
||||||
|
owner_name: settings.owner_name,
|
||||||
|
owner_avatar_url: settings.owner_avatar_url,
|
||||||
homepage_headline: settings.homepage_headline,
|
homepage_headline: settings.homepage_headline,
|
||||||
homepage_description: settings.homepage_description,
|
homepage_description: settings.homepage_description,
|
||||||
homepage_features: settings.homepage_features,
|
homepage_features: settings.homepage_features,
|
||||||
@@ -311,6 +323,28 @@ export function BrandingTab() {
|
|||||||
setFaviconPreview(null);
|
setFaviconPreview(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOwnerAvatarUpload = async (file: File) => {
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast({ title: 'Error', description: 'Ukuran file maksimal 2MB', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploadingOwnerAvatar(true);
|
||||||
|
const ext = file.name.split('.').pop() || 'png';
|
||||||
|
const path = `brand-assets/logo/owner-avatar-${Date.now()}.${ext}`;
|
||||||
|
const publicUrl = await uploadToContentStorage(file, path);
|
||||||
|
setSettings((prev) => ({ ...prev, owner_avatar_url: publicUrl }));
|
||||||
|
toast({ title: 'Berhasil', description: 'Avatar owner berhasil diupload' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Owner avatar upload error:', error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Gagal upload avatar owner';
|
||||||
|
toast({ title: 'Error', description: message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setUploadingOwnerAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -595,6 +629,54 @@ export function BrandingTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="font-semibold mb-4">Identitas Owner</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nama Owner</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.owner_name}
|
||||||
|
onChange={(e) => setSettings({ ...settings, owner_name: e.target.value })}
|
||||||
|
placeholder="Dwindi"
|
||||||
|
className="border-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Avatar Owner</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-16 w-16 border-2 border-border">
|
||||||
|
<AvatarImage src={resolveAvatarUrl(settings.owner_avatar_url) || undefined} alt={settings.owner_name} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-muted">
|
||||||
|
<User className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
void handleOwnerAvatarUpload(file);
|
||||||
|
e.currentTarget.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" asChild disabled={uploadingOwnerAvatar}>
|
||||||
|
<span>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploadingOwnerAvatar ? 'Mengupload...' : 'Upload Avatar'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
120
src/components/admin/settings/CollaborationTab.tsx
Normal file
120
src/components/admin/settings/CollaborationTab.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/hooks/useOwnerIdentity.tsx
Normal file
41
src/hooks/useOwnerIdentity.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { resolveAvatarUrl } from "@/lib/avatar";
|
||||||
|
|
||||||
|
export interface OwnerIdentity {
|
||||||
|
owner_name: string;
|
||||||
|
owner_avatar_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackOwner: OwnerIdentity = {
|
||||||
|
owner_name: "Dwindi",
|
||||||
|
owner_avatar_url: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useOwnerIdentity() {
|
||||||
|
const [owner, setOwner] = useState<OwnerIdentity>(fallbackOwner);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOwnerIdentity = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke("get-owner-identity");
|
||||||
|
if (error) throw error;
|
||||||
|
if (data) {
|
||||||
|
setOwner({
|
||||||
|
owner_name: data.owner_name || fallbackOwner.owner_name,
|
||||||
|
owner_avatar_url: resolveAvatarUrl(data.owner_avatar_url) || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load owner identity:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchOwnerIdentity();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { owner, loading };
|
||||||
|
}
|
||||||
11
src/lib/avatar.ts
Normal file
11
src/lib/avatar.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
|
export function resolveAvatarUrl(value?: string | null): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) return value;
|
||||||
|
|
||||||
|
const normalized = value.startsWith("/") ? value.slice(1) : value;
|
||||||
|
const { data } = supabase.storage.from("content").getPublicUrl(normalized);
|
||||||
|
return data.publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
17
src/lib/storageUpload.ts
Normal file
17
src/lib/storageUpload.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
|
export async function uploadToContentStorage(
|
||||||
|
file: File,
|
||||||
|
path: string,
|
||||||
|
options?: { upsert?: boolean }
|
||||||
|
): Promise<string> {
|
||||||
|
const { error } = await supabase.storage.from("content").upload(path, file, {
|
||||||
|
cacheControl: "3600",
|
||||||
|
upsert: options?.upsert ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const { data } = supabase.storage.from("content").getPublicUrl(path);
|
||||||
|
return data.publicUrl;
|
||||||
|
}
|
||||||
@@ -5,15 +5,18 @@ import { AppLayout } from '@/components/AppLayout';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { formatIDR, formatDuration } from '@/lib/format';
|
import { formatIDR, formatDuration } from '@/lib/format';
|
||||||
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight, Star, CheckCircle, Lock } from 'lucide-react';
|
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight, Star, CheckCircle, Lock, User } from 'lucide-react';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||||
import { ProductReviews } from '@/components/reviews/ProductReviews';
|
import { ProductReviews } from '@/components/reviews/ProductReviews';
|
||||||
|
import { useOwnerIdentity } from '@/hooks/useOwnerIdentity';
|
||||||
|
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,6 +36,7 @@ interface Product {
|
|||||||
duration_minutes: number | null;
|
duration_minutes: number | null;
|
||||||
chapters?: { time: number; title: string; }[];
|
chapters?: { time: number; title: string; }[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
collaborator_user_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
@@ -71,8 +75,10 @@ export default function ProductDetail() {
|
|||||||
const [expandedLessonChapters, setExpandedLessonChapters] = useState<Set<string>>(new Set());
|
const [expandedLessonChapters, setExpandedLessonChapters] = useState<Set<string>>(new Set());
|
||||||
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||||
|
const [collaborator, setCollaborator] = useState<{ name: string; avatar_url: string | null } | null>(null);
|
||||||
const { addItem, items } = useCart();
|
const { addItem, items } = useCart();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { owner } = useOwnerIdentity();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (slug) fetchProduct();
|
if (slug) fetchProduct();
|
||||||
@@ -93,6 +99,28 @@ export default function ProductDetail() {
|
|||||||
}
|
}
|
||||||
}, [product]);
|
}, [product]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCollaborator = async () => {
|
||||||
|
if (!product?.collaborator_user_id) {
|
||||||
|
setCollaborator(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('name, avatar_url')
|
||||||
|
.eq('id', product.collaborator_user_id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
setCollaborator({
|
||||||
|
name: data?.name || 'Builder',
|
||||||
|
avatar_url: data?.avatar_url || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchCollaborator();
|
||||||
|
}, [product?.collaborator_user_id]);
|
||||||
|
|
||||||
const fetchProduct = async () => {
|
const fetchProduct = async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
@@ -534,6 +562,7 @@ export default function ProductDetail() {
|
|||||||
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge>
|
<Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge>
|
||||||
|
{product.collaborator_user_id && <Badge variant="secondary">Collab</Badge>}
|
||||||
{product.type === 'webinar' && hasRecording() && (
|
{product.type === 'webinar' && hasRecording() && (
|
||||||
<Badge className="bg-secondary text-primary">Rekaman Tersedia</Badge>
|
<Badge className="bg-secondary text-primary">Rekaman Tersedia</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -544,6 +573,33 @@ export default function ProductDetail() {
|
|||||||
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
{product.collaborator_user_id ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
<Avatar className="h-8 w-8 border-2 border-background">
|
||||||
|
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
|
||||||
|
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar className="h-8 w-8 border-2 border-background">
|
||||||
|
<AvatarImage src={resolveAvatarUrl(collaborator?.avatar_url) || undefined} alt={collaborator?.name || 'Builder'} />
|
||||||
|
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
Hosted by {owner.owner_name} • with {collaborator?.name || 'Builder'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Avatar className="h-8 w-8 border border-border">
|
||||||
|
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
|
||||||
|
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>Hosted by {owner.owner_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
{product.sale_price ? (
|
{product.sale_price ? (
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { AppLayout } from '@/components/AppLayout';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { formatIDR } from '@/lib/format';
|
import { formatIDR } from '@/lib/format';
|
||||||
import { Video, Package, Check, Search, X } from 'lucide-react';
|
import { Video, Package, Check, Search, X, User } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useOwnerIdentity } from '@/hooks/useOwnerIdentity';
|
||||||
|
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,6 +24,13 @@ interface Product {
|
|||||||
price: number;
|
price: number;
|
||||||
sale_price: number | null;
|
sale_price: number | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
collaborator_user_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollaboratorProfile {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConsultingSettings {
|
interface ConsultingSettings {
|
||||||
@@ -35,7 +45,9 @@ export default function Products() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedType, setSelectedType] = useState<string>('all');
|
const [selectedType, setSelectedType] = useState<string>('all');
|
||||||
|
const [collaborators, setCollaborators] = useState<Record<string, CollaboratorProfile>>({});
|
||||||
const { addItem, items } = useCart();
|
const { addItem, items } = useCart();
|
||||||
|
const { owner } = useOwnerIdentity();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -57,7 +69,33 @@ export default function Products() {
|
|||||||
if (productsRes.error) {
|
if (productsRes.error) {
|
||||||
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
|
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
|
||||||
} else {
|
} else {
|
||||||
setProducts(productsRes.data || []);
|
const productsData = productsRes.data || [];
|
||||||
|
setProducts(productsData);
|
||||||
|
|
||||||
|
const collaboratorIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
productsData
|
||||||
|
.map((p) => p.collaborator_user_id)
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collaboratorIds.length > 0) {
|
||||||
|
const { data: collaboratorRows } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, name, avatar_url')
|
||||||
|
.in('id', collaboratorIds);
|
||||||
|
|
||||||
|
if (collaboratorRows) {
|
||||||
|
const byId = collaboratorRows.reduce<Record<string, CollaboratorProfile>>((acc, row) => {
|
||||||
|
acc[row.id] = row;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
setCollaborators(byId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCollaborators({});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (consultingRes.data) {
|
if (consultingRes.data) {
|
||||||
@@ -232,10 +270,38 @@ export default function Products() {
|
|||||||
<Card key={product.id} className="border-2 border-border shadow-sm hover:shadow-md transition-shadow h-full flex flex-col">
|
<Card key={product.id} className="border-2 border-border shadow-sm hover:shadow-md transition-shadow h-full flex flex-col">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex justify-between items-start gap-2 mb-2">
|
<div className="flex justify-between items-start gap-2 mb-2">
|
||||||
<CardTitle className="text-xl line-clamp-2 leading-tight min-h-[2.5rem]">{product.title}</CardTitle>
|
<CardTitle className="text-xl line-clamp-2 leading-tight min-h-[3rem]">{product.title}</CardTitle>
|
||||||
<Badge className="shrink-0">
|
<div className="flex items-center gap-2">
|
||||||
{getTypeLabel(product.type)}
|
<Badge className="shrink-0">{getTypeLabel(product.type)}</Badge>
|
||||||
</Badge>
|
{product.collaborator_user_id && <Badge variant="secondary">Collab</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
{product.collaborator_user_id ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
<Avatar className="h-7 w-7 border-2 border-background">
|
||||||
|
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
|
||||||
|
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar className="h-7 w-7 border-2 border-background">
|
||||||
|
<AvatarImage src={resolveAvatarUrl(collaborators[product.collaborator_user_id]?.avatar_url) || undefined} alt={collaborators[product.collaborator_user_id]?.name || 'Collaborator'} />
|
||||||
|
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{owner.owner_name} (Host) • {(collaborators[product.collaborator_user_id]?.name || 'Builder')} (Builder)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Avatar className="h-7 w-7 border border-border">
|
||||||
|
<AvatarImage src={owner.owner_avatar_url || undefined} alt={owner.owner_name} />
|
||||||
|
<AvatarFallback><User className="h-3.5 w-3.5" /></AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{owner.owner_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="line-clamp-2">
|
<CardDescription className="line-clamp-2">
|
||||||
{stripHtml(product.description)}
|
{stripHtml(product.description)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Plus, Pencil, Trash2, Search, X, BookOpen } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search, X, BookOpen, ChevronsUpDown } from 'lucide-react';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
import { formatIDR } from '@/lib/format';
|
import { formatIDR } from '@/lib/format';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
@@ -45,6 +47,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 +75,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 +92,36 @@ 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[]>([]);
|
||||||
|
const [collaboratorPickerOpen, setCollaboratorPickerOpen] = useState(false);
|
||||||
|
|
||||||
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()) ||
|
||||||
@@ -107,7 +135,6 @@ export default function AdminProducts() {
|
|||||||
|
|
||||||
// Get unique product types from actual products
|
// Get unique product types from actual products
|
||||||
const productTypes = ['all', ...Array.from(new Set(products.map(p => p.type)))];
|
const productTypes = ['all', ...Array.from(new Set(products.map(p => p.type)))];
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setFilterType('all');
|
setFilterType('all');
|
||||||
@@ -135,6 +162,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 +198,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 {
|
} else {
|
||||||
const { error } = await supabase.from('products').insert(productData);
|
const prevCollaboratorId = editingProduct.collaborator_user_id || null;
|
||||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
const nextCollaboratorId = productData.collaborator_user_id;
|
||||||
else { toast({ title: 'Berhasil', description: 'Produk dibuat' }); setDialogOpen(false); fetchProducts(); }
|
|
||||||
|
// 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 {
|
||||||
|
const { data: created, error } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.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 +555,95 @@ 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>
|
||||||
|
<Popover open={collaboratorPickerOpen} onOpenChange={setCollaboratorPickerOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between border-2">
|
||||||
|
{form.collaborator_user_id
|
||||||
|
? (() => {
|
||||||
|
const selected = collaborators.find((c) => c.id === form.collaborator_user_id);
|
||||||
|
return selected ? `${selected.name || 'User'}${selected.email ? ` (${selected.email})` : ''}` : 'Pilih kolaborator';
|
||||||
|
})()
|
||||||
|
: 'Tanpa kolaborator (solo)'}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Cari nama atau email..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Tidak ada kolaborator yang cocok.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="Tanpa kolaborator (solo)"
|
||||||
|
onSelect={() => {
|
||||||
|
setForm({ ...form, collaborator_user_id: '' });
|
||||||
|
setCollaboratorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tanpa kolaborator (solo)
|
||||||
|
</CommandItem>
|
||||||
|
{collaborators.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.id}
|
||||||
|
value={`${c.name || 'User'} ${c.email || ''}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setForm({ ...form, collaborator_user_id: c.id });
|
||||||
|
setCollaboratorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(c.name || 'User') + (c.email ? ` (${c.email})` : '')}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</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 })} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
217
src/pages/admin/AdminWithdrawals.tsx
Normal file
217
src/pages/admin/AdminWithdrawals.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { User, LogOut, Phone } from 'lucide-react';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { User, LogOut, Phone, Upload } from 'lucide-react';
|
||||||
|
import { uploadToContentStorage } from '@/lib/storageUpload';
|
||||||
|
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +22,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() {
|
||||||
@@ -27,11 +35,17 @@ export default function MemberProfile() {
|
|||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
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 +65,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 +100,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);
|
||||||
|
|
||||||
@@ -93,6 +117,29 @@ export default function MemberProfile() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (file: File) => {
|
||||||
|
if (!user) return;
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast({ title: 'Error', description: 'Ukuran file maksimal 2MB', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploadingAvatar(true);
|
||||||
|
const ext = file.name.split('.').pop() || 'png';
|
||||||
|
const path = `users/${user.id}/avatar-${Date.now()}.${ext}`;
|
||||||
|
const publicUrl = await uploadToContentStorage(file, path);
|
||||||
|
setForm((prev) => ({ ...prev, avatar_url: publicUrl }));
|
||||||
|
toast({ title: 'Berhasil', description: 'Avatar berhasil diupload' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Avatar upload error:', error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Gagal upload avatar';
|
||||||
|
toast({ title: 'Error', description: message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
@@ -138,10 +185,52 @@ export default function MemberProfile() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>URL Avatar</Label>
|
<Label>Avatar</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-16 w-16 border-2 border-border">
|
||||||
|
<AvatarImage src={resolveAvatarUrl(form.avatar_url) || undefined} alt={form.name || 'User avatar'} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-muted">
|
||||||
|
<User className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
void handleAvatarUpload(file);
|
||||||
|
e.currentTarget.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" asChild disabled={uploadingAvatar}>
|
||||||
|
<span>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploadingAvatar ? 'Mengupload...' : 'Upload Avatar'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Bio</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.avatar_url}
|
value={form.bio}
|
||||||
onChange={(e) => setForm({ ...form, avatar_url: e.target.value })}
|
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"
|
className="border-2"
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
@@ -184,6 +273,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>
|
||||||
|
|||||||
239
src/pages/member/MemberProfit.tsx
Normal file
239
src/pages/member/MemberProfit.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
supabase/functions/create-withdrawal/index.ts
Normal file
164
supabase/functions/create-withdrawal/index.ts
Normal 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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
50
supabase/functions/get-owner-identity/index.ts
Normal file
50
supabase/functions/get-owner-identity/index.ts
Normal 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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
154
supabase/functions/process-withdrawal/index.ts
Normal file
154
supabase/functions/process-withdrawal/index.ts
Normal 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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
172
supabase/functions/send-collaboration-notification/index.ts
Normal file
172
supabase/functions/send-collaboration-notification/index.ts
Normal 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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
117
supabase/migrations/20260203071000_content_storage_policies.sql
Normal file
117
supabase/migrations/20260203071000_content_storage_policies.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- Storage policies for content bucket uploads used by:
|
||||||
|
-- - Admin branding owner avatar/logo/favicon
|
||||||
|
-- - Member profile avatar
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_policies
|
||||||
|
WHERE schemaname = 'storage'
|
||||||
|
AND tablename = 'objects'
|
||||||
|
AND policyname = 'content_public_read'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY "content_public_read"
|
||||||
|
ON storage.objects
|
||||||
|
FOR SELECT
|
||||||
|
USING (bucket_id = 'content');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_policies
|
||||||
|
WHERE schemaname = 'storage'
|
||||||
|
AND tablename = 'objects'
|
||||||
|
AND policyname = 'content_admin_manage'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY "content_admin_manage"
|
||||||
|
ON storage.objects
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.user_roles ur
|
||||||
|
WHERE ur.user_id = auth.uid()
|
||||||
|
AND ur.role = 'admin'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.user_roles ur
|
||||||
|
WHERE ur.user_id = auth.uid()
|
||||||
|
AND ur.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_policies
|
||||||
|
WHERE schemaname = 'storage'
|
||||||
|
AND tablename = 'objects'
|
||||||
|
AND policyname = 'content_user_avatar_insert'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY "content_user_avatar_insert"
|
||||||
|
ON storage.objects
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND name LIKE ('users/' || auth.uid()::text || '/%')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_policies
|
||||||
|
WHERE schemaname = 'storage'
|
||||||
|
AND tablename = 'objects'
|
||||||
|
AND policyname = 'content_user_avatar_update'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY "content_user_avatar_update"
|
||||||
|
ON storage.objects
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND name LIKE ('users/' || auth.uid()::text || '/%')
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND name LIKE ('users/' || auth.uid()::text || '/%')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_policies
|
||||||
|
WHERE schemaname = 'storage'
|
||||||
|
AND tablename = 'objects'
|
||||||
|
AND policyname = 'content_user_avatar_delete'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY "content_user_avatar_delete"
|
||||||
|
ON storage.objects
|
||||||
|
FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'content'
|
||||||
|
AND name LIKE ('users/' || auth.uid()::text || '/%')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
Reference in New Issue
Block a user