diff --git a/src/components/admin/settings/BrandingTab.tsx b/src/components/admin/settings/BrandingTab.tsx index d43d2b3..de76bd7 100644 --- a/src/components/admin/settings/BrandingTab.tsx +++ b/src/components/admin/settings/BrandingTab.tsx @@ -5,8 +5,11 @@ 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 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 { icon: string; @@ -22,6 +25,8 @@ interface PlatformSettings { brand_favicon_url: string; brand_primary_color: string; brand_accent_color: string; + owner_name: string; + owner_avatar_url: string; homepage_headline: string; homepage_description: string; homepage_features: HomepageFeature[]; @@ -40,6 +45,8 @@ const emptySettings: PlatformSettings = { brand_favicon_url: '', brand_primary_color: '#111827', brand_accent_color: '#0F766E', + owner_name: 'Dwindi', + owner_avatar_url: '', homepage_headline: 'Learn. Grow. Succeed.', homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.', homepage_features: defaultFeatures, @@ -53,6 +60,7 @@ export function BrandingTab() { const [saving, setSaving] = useState(false); const [uploadingLogo, setUploadingLogo] = useState(false); const [uploadingFavicon, setUploadingFavicon] = useState(false); + const [uploadingOwnerAvatar, setUploadingOwnerAvatar] = useState(false); // Preview states for selected files const [logoPreview, setLogoPreview] = useState(null); @@ -91,6 +99,8 @@ export function BrandingTab() { brand_favicon_url: data.brand_favicon_url || '', brand_primary_color: data.brand_primary_color || '#111827', 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_description: data.homepage_description || emptySettings.homepage_description, homepage_features: features, @@ -109,6 +119,8 @@ export function BrandingTab() { brand_favicon_url: settings.brand_favicon_url, brand_primary_color: settings.brand_primary_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_description: settings.homepage_description, homepage_features: settings.homepage_features, @@ -311,6 +323,28 @@ export function BrandingTab() { 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
; return ( @@ -595,6 +629,54 @@ export function BrandingTab() {
+ +
+

Identitas Owner

+
+
+ + setSettings({ ...settings, owner_name: e.target.value })} + placeholder="Dwindi" + className="border-2" + /> +
+
+ +
+ + + +
+ +
+
+
+ +
+
+
+
diff --git a/src/components/admin/settings/CollaborationTab.tsx b/src/components/admin/settings/CollaborationTab.tsx index 3fd3e99..552c564 100644 --- a/src/components/admin/settings/CollaborationTab.tsx +++ b/src/components/admin/settings/CollaborationTab.tsx @@ -118,4 +118,3 @@ export function CollaborationTab() { ); } - diff --git a/src/hooks/useOwnerIdentity.tsx b/src/hooks/useOwnerIdentity.tsx new file mode 100644 index 0000000..13ea0f1 --- /dev/null +++ b/src/hooks/useOwnerIdentity.tsx @@ -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(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 }; +} diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts new file mode 100644 index 0000000..6810d55 --- /dev/null +++ b/src/lib/avatar.ts @@ -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; +} + diff --git a/src/lib/storageUpload.ts b/src/lib/storageUpload.ts new file mode 100644 index 0000000..820f710 --- /dev/null +++ b/src/lib/storageUpload.ts @@ -0,0 +1,17 @@ +import { supabase } from "@/integrations/supabase/client"; + +export async function uploadToContentStorage( + file: File, + path: string, + options?: { upsert?: boolean } +): Promise { + 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; +} diff --git a/src/pages/ProductDetail.tsx b/src/pages/ProductDetail.tsx index 0be6721..5ed9c90 100644 --- a/src/pages/ProductDetail.tsx +++ b/src/pages/ProductDetail.tsx @@ -5,15 +5,18 @@ import { AppLayout } from '@/components/AppLayout'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useCart } from '@/contexts/CartContext'; import { useAuth } from '@/hooks/useAuth'; import { toast } from '@/hooks/use-toast'; import { Skeleton } from '@/components/ui/skeleton'; 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 { ReviewModal } from '@/components/reviews/ReviewModal'; import { ProductReviews } from '@/components/reviews/ProductReviews'; +import { useOwnerIdentity } from '@/hooks/useOwnerIdentity'; +import { resolveAvatarUrl } from '@/lib/avatar'; interface Product { id: string; @@ -33,6 +36,7 @@ interface Product { duration_minutes: number | null; chapters?: { time: number; title: string; }[]; created_at: string; + collaborator_user_id?: string | null; } interface Module { @@ -71,8 +75,10 @@ export default function ProductDetail() { const [expandedLessonChapters, setExpandedLessonChapters] = useState>(new Set()); const [userReview, setUserReview] = useState(null); const [reviewModalOpen, setReviewModalOpen] = useState(false); + const [collaborator, setCollaborator] = useState<{ name: string; avatar_url: string | null } | null>(null); const { addItem, items } = useCart(); const { user } = useAuth(); + const { owner } = useOwnerIdentity(); useEffect(() => { if (slug) fetchProduct(); @@ -93,6 +99,28 @@ export default function ProductDetail() { } }, [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 { data, error } = await supabase .from('products') @@ -534,6 +562,7 @@ export default function ProductDetail() {

{product.title}

{product.type} + {product.collaborator_user_id && Collab} {product.type === 'webinar' && hasRecording() && ( Rekaman Tersedia )} @@ -544,6 +573,33 @@ export default function ProductDetail() { Telah Lewat )}
+
+ {product.collaborator_user_id ? ( +
+
+ + + + + + + + +
+ + Hosted by {owner.owner_name} • with {collaborator?.name || 'Builder'} + +
+ ) : ( +
+ + + + + Hosted by {owner.owner_name} +
+ )} +
{product.sale_price ? ( diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx index f058141..0ed3287 100644 --- a/src/pages/Products.tsx +++ b/src/pages/Products.tsx @@ -5,12 +5,15 @@ import { AppLayout } from '@/components/AppLayout'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useCart } from '@/contexts/CartContext'; import { toast } from '@/hooks/use-toast'; import { Skeleton } from '@/components/ui/skeleton'; 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 { useOwnerIdentity } from '@/hooks/useOwnerIdentity'; +import { resolveAvatarUrl } from '@/lib/avatar'; interface Product { id: string; @@ -21,6 +24,13 @@ interface Product { price: number; sale_price: number | null; is_active: boolean; + collaborator_user_id?: string | null; +} + +interface CollaboratorProfile { + id: string; + name: string | null; + avatar_url: string | null; } interface ConsultingSettings { @@ -35,7 +45,9 @@ export default function Products() { const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [selectedType, setSelectedType] = useState('all'); + const [collaborators, setCollaborators] = useState>({}); const { addItem, items } = useCart(); + const { owner } = useOwnerIdentity(); useEffect(() => { fetchData(); @@ -57,7 +69,33 @@ export default function Products() { if (productsRes.error) { toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' }); } 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>((acc, row) => { + acc[row.id] = row; + return acc; + }, {}); + setCollaborators(byId); + } + } else { + setCollaborators({}); + } } if (consultingRes.data) { @@ -232,10 +270,38 @@ export default function Products() {
- {product.title} - - {getTypeLabel(product.type)} - + {product.title} +
+ {getTypeLabel(product.type)} + {product.collaborator_user_id && Collab} +
+
+
+ {product.collaborator_user_id ? ( +
+
+ + + + + + + + +
+ + {owner.owner_name} (Host) • {(collaborators[product.collaborator_user_id]?.name || 'Builder')} (Builder) + +
+ ) : ( +
+ + + + + {owner.owner_name} +
+ )}
{stripHtml(product.description)} diff --git a/src/pages/member/MemberProfile.tsx b/src/pages/member/MemberProfile.tsx index 7761c47..246c44f 100644 --- a/src/pages/member/MemberProfile.tsx +++ b/src/pages/member/MemberProfile.tsx @@ -10,7 +10,10 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Skeleton } from '@/components/ui/skeleton'; 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 { id: string; @@ -32,6 +35,7 @@ export default function MemberProfile() { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [uploadingAvatar, setUploadingAvatar] = useState(false); const [form, setForm] = useState({ name: '', avatar_url: '', @@ -113,6 +117,29 @@ export default function MemberProfile() { 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 () => { await signOut(); navigate('/'); @@ -158,13 +185,37 @@ export default function MemberProfile() { />
- - setForm({ ...form, avatar_url: e.target.value })} - className="border-2" - placeholder="https://..." - /> + +
+ + + +
+ +
+
+
+ +
diff --git a/supabase/migrations/20260203071000_content_storage_policies.sql b/supabase/migrations/20260203071000_content_storage_policies.sql new file mode 100644 index 0000000..a0a191e --- /dev/null +++ b/supabase/migrations/20260203071000_content_storage_policies.sql @@ -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 $$; +