Compare commits
50 Commits
eee6339074
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b4496dca | ||
|
|
d58f597ba6 | ||
|
|
8be40dc0f9 | ||
|
|
52b16dce07 | ||
|
|
8e64780f72 | ||
|
|
da84d0e44d | ||
|
|
f3117308c3 | ||
|
|
d47be3aca6 | ||
|
|
221ae195e9 | ||
|
|
ca163e13cf | ||
|
|
713d881445 | ||
|
|
9c2f367447 | ||
|
|
d0d824a661 | ||
|
|
e268ef7756 | ||
|
|
bfc1f505bc | ||
|
|
1ef85a22d5 | ||
|
|
7165fcee9b | ||
|
|
a1ba5f342b | ||
|
|
a801e2d344 | ||
|
|
269e384665 | ||
|
|
d6126d1943 | ||
|
|
a423a6d31d | ||
|
|
87539eb51f | ||
|
|
e4a09a676e | ||
|
|
e79e982401 | ||
|
|
aeeb02d36b | ||
|
|
47a645520c | ||
|
|
8d40a8cb29 | ||
|
|
d126f2d9c6 | ||
|
|
7cc8d47ecf | ||
|
|
71d6da4530 | ||
|
|
8fc31b402d | ||
|
|
15760d6430 | ||
|
|
ab7033b82e | ||
|
|
b7bde1df04 | ||
|
|
2b98a5460d | ||
|
|
44484afb84 | ||
|
|
963160d165 | ||
|
|
ce10be63f3 | ||
|
|
8217261706 | ||
|
|
053465afa3 | ||
|
|
4f9a6f4ae3 | ||
|
|
9f8ee0d7d2 | ||
|
|
1fbaf4d360 | ||
|
|
485263903f | ||
|
|
00de020b6c | ||
|
|
5f753464fd | ||
|
|
1749056542 | ||
|
|
2ce5c2efe8 | ||
|
|
72799b981d |
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
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.9 KiB |
157
src/App.tsx
157
src/App.tsx
@@ -6,8 +6,10 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "@/hooks/useAuth";
|
||||
import { CartProvider } from "@/contexts/CartContext";
|
||||
import { BrandingProvider } from "@/hooks/useBranding";
|
||||
import { ProtectedRoute } from "@/components/ProtectedRoute";
|
||||
import Index from "./pages/Index";
|
||||
import Auth from "./pages/Auth";
|
||||
import ConfirmOTP from "./pages/ConfirmOTP";
|
||||
import Products from "./pages/Products";
|
||||
import ProductDetail from "./pages/ProductDetail";
|
||||
import Checkout from "./pages/Checkout";
|
||||
@@ -26,6 +28,7 @@ import MemberAccess from "./pages/member/MemberAccess";
|
||||
import MemberOrders from "./pages/member/MemberOrders";
|
||||
import MemberProfile from "./pages/member/MemberProfile";
|
||||
import OrderDetail from "./pages/member/OrderDetail";
|
||||
import MemberProfit from "./pages/member/MemberProfit";
|
||||
|
||||
// Admin pages
|
||||
import AdminDashboard from "./pages/admin/AdminDashboard";
|
||||
@@ -38,6 +41,7 @@ import AdminSettings from "./pages/admin/AdminSettings";
|
||||
import AdminConsulting from "./pages/admin/AdminConsulting";
|
||||
import AdminReviews from "./pages/admin/AdminReviews";
|
||||
import ProductCurriculum from "./pages/admin/ProductCurriculum";
|
||||
import AdminWithdrawals from "./pages/admin/AdminWithdrawals";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -53,6 +57,7 @@ const App = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/confirm-otp" element={<ConfirmOTP />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/products/:slug" element={<ProductDetail />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
@@ -65,23 +70,145 @@ const App = () => (
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
|
||||
{/* Member routes */}
|
||||
<Route path="/dashboard" element={<MemberDashboard />} />
|
||||
<Route path="/access" element={<MemberAccess />} />
|
||||
<Route path="/orders" element={<MemberOrders />} />
|
||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||
<Route path="/profile" element={<MemberProfile />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MemberDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/access"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MemberAccess />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/orders"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MemberOrders />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/orders/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<OrderDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MemberProfile />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MemberProfit />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
<Route path="/admin/products" element={<AdminProducts />} />
|
||||
<Route path="/admin/products/:id/curriculum" element={<ProductCurriculum />} />
|
||||
<Route path="/admin/bootcamp" element={<AdminBootcamp />} />
|
||||
<Route path="/admin/orders" element={<AdminOrders />} />
|
||||
<Route path="/admin/members" element={<AdminMembers />} />
|
||||
<Route path="/admin/events" element={<AdminEvents />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/admin/consulting" element={<AdminConsulting />} />
|
||||
<Route path="/admin/reviews" element={<AdminReviews />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/products"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminProducts />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/products/:id/curriculum"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<ProductCurriculum />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/bootcamp"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminBootcamp />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/orders"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminOrders />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/members"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminMembers />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/events"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminEvents />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminSettings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/consulting"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminConsulting />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/reviews"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminReviews />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminWithdrawals />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { useBranding } from '@/hooks/useBranding';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Footer } from '@/components/Footer';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
X,
|
||||
Video,
|
||||
Star,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
@@ -46,6 +48,7 @@ const adminNavItems: NavItem[] = [
|
||||
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video },
|
||||
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||
{ label: 'Withdrawals', href: '/admin/withdrawals', icon: Wallet },
|
||||
{ label: 'Ulasan', href: '/admin/reviews', icon: Star },
|
||||
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
|
||||
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings },
|
||||
@@ -76,9 +79,36 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [isCollaborator, setIsCollaborator] = useState(false);
|
||||
|
||||
const navItems = isAdmin ? adminNavItems : userNavItems;
|
||||
const mobileNav = isAdmin ? mobileAdminNav : mobileUserNav;
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
await signOut();
|
||||
|
||||
58
src/components/ProtectedRoute.tsx
Normal file
58
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
||||
const { user, loading: authLoading, isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
// Save current URL to redirect back after login
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
sessionStorage.setItem('redirectAfterLogin', currentPath);
|
||||
navigate('/auth');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for admin role if required (only after user is loaded AND admin check is complete)
|
||||
if (!authLoading && user && requireAdmin && !isAdmin) {
|
||||
// Redirect non-admin users to member dashboard
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [user, authLoading, isAdmin, navigate, requireAdmin]);
|
||||
|
||||
// Show loading skeleton while checking auth
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render children if user is not authenticated
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render if admin access required but user is not admin
|
||||
if (requireAdmin && !isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number; // Time in seconds
|
||||
@@ -13,6 +14,7 @@ interface TimelineChaptersProps {
|
||||
onChapterClick?: (time: number) => void;
|
||||
currentTime?: number; // Current video playback time in seconds
|
||||
accentColor?: string;
|
||||
clickable?: boolean; // Control whether chapters are clickable
|
||||
}
|
||||
|
||||
export function TimelineChapters({
|
||||
@@ -20,6 +22,7 @@ export function TimelineChapters({
|
||||
onChapterClick,
|
||||
currentTime = 0,
|
||||
accentColor = '#f97316',
|
||||
clickable = true,
|
||||
}: TimelineChaptersProps) {
|
||||
// Format time in seconds to MM:SS or HH:MM:SS
|
||||
const formatTime = (seconds: number): string => {
|
||||
@@ -56,17 +59,19 @@ export function TimelineChapters({
|
||||
</div>
|
||||
|
||||
{/* Scrollable chapter list with max-height */}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-1 pr-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 hover:scrollbar-thumb-gray-400">
|
||||
<div className="max-h-[400px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 hover:scrollbar-thumb-gray-400">
|
||||
{chapters.map((chapter, index) => {
|
||||
const active = isChapterActive(index);
|
||||
const isLast = index === chapters.length - 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onChapterClick && onChapterClick(chapter.time)}
|
||||
onClick={() => clickable && onChapterClick && onChapterClick(chapter.time)}
|
||||
disabled={!clickable}
|
||||
className={`
|
||||
w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left
|
||||
hover:bg-muted cursor-pointer
|
||||
w-full flex items-start gap-3 p-3 rounded-lg transition-all text-left
|
||||
${clickable ? 'hover:bg-muted cursor-pointer' : 'cursor-not-allowed opacity-75'}
|
||||
${active
|
||||
? `bg-primary/10 border-l-4`
|
||||
: 'border-l-4 border-transparent'
|
||||
@@ -77,28 +82,34 @@ export function TimelineChapters({
|
||||
? { borderColor: accentColor, backgroundColor: `${accentColor}10` }
|
||||
: undefined
|
||||
}
|
||||
title={`Klik untuk lompat ke ${formatTime(chapter.time)}`}
|
||||
title={clickable ? `Klik untuk lompat ke ${formatTime(chapter.time)}` : 'Belum membeli produk ini'}
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<div className={`
|
||||
font-mono text-sm font-semibold
|
||||
font-mono text-sm font-semibold shrink-0 pt-0.5
|
||||
${active ? 'text-primary' : 'text-muted-foreground'}
|
||||
`} style={active ? { color: accentColor } : undefined}>
|
||||
{formatTime(chapter.time)}
|
||||
</div>
|
||||
|
||||
{/* Chapter Title */}
|
||||
<div className={`
|
||||
flex-1 text-sm
|
||||
{/* Chapter Title - supports HTML with sanitized output */}
|
||||
<div
|
||||
className={`
|
||||
flex-1 text-sm prose prose-sm max-w-none
|
||||
${active ? 'font-medium' : 'text-muted-foreground'}
|
||||
`}>
|
||||
{chapter.title}
|
||||
</div>
|
||||
`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(chapter.title, {
|
||||
ALLOWED_TAGS: ['code', 'strong', 'em', 'b', 'i', 'u', 'br', 'p', 'span'],
|
||||
ALLOWED_ATTR: ['class', 'style'],
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Active indicator */}
|
||||
{active && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
className="w-2 h-2 rounded-full shrink-0 mt-1.5"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||
const [resumeTime, setResumeTime] = useState(0);
|
||||
const saveProgressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasShownResumePromptRef = useRef(false);
|
||||
|
||||
// Determine if using Adilo (M3U8) or YouTube
|
||||
const isAdilo = videoHost === 'adilo' || m3u8Url;
|
||||
@@ -228,35 +229,53 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
}, [isYouTube, findCurrentChapter, onChapterChange, onTimeUpdate, chapters, saveProgressDebounced]);
|
||||
|
||||
// Jump to specific time using Plyr API or Adilo player
|
||||
const jumpToTime = (time: number) => {
|
||||
const jumpToTime = useCallback((time: number) => {
|
||||
if (isAdilo) {
|
||||
const video = adiloPlayer.videoRef.current;
|
||||
if (video && adiloPlayer.isReady) {
|
||||
|
||||
if (!video) {
|
||||
console.warn('Video element not available for jump');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to jump immediately if video is seekable
|
||||
if (video.seekable && video.seekable.length > 0) {
|
||||
console.log(`🎯 Jumping to ${time}s (video seekable)`);
|
||||
video.currentTime = time;
|
||||
const wasPlaying = !video.paused;
|
||||
if (wasPlaying) {
|
||||
video.play().catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Jump failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Video not seekable yet, wait for it to be ready
|
||||
console.log(`⏳ Video not seekable yet, waiting to jump to ${time}s`);
|
||||
|
||||
const onCanPlay = () => {
|
||||
console.log(`🎯 Video seekable now, jumping to ${time}s`);
|
||||
video.currentTime = time;
|
||||
video.removeEventListener('canplay', onCanPlay);
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', onCanPlay, { once: true });
|
||||
}
|
||||
} else if (playerInstance) {
|
||||
playerInstance.currentTime = time;
|
||||
playerInstance.play();
|
||||
}
|
||||
};
|
||||
}, [isAdilo, adiloPlayer.videoRef, playerInstance]);
|
||||
|
||||
const getCurrentTime = () => {
|
||||
return currentTime;
|
||||
};
|
||||
|
||||
// Check for saved progress and show resume prompt
|
||||
// Reset resume prompt flag when videoId changes (switching lessons)
|
||||
useEffect(() => {
|
||||
if (!progressLoading && hasProgress && progress && progress.last_position > 5) {
|
||||
hasShownResumePromptRef.current = false;
|
||||
setShowResumePrompt(false);
|
||||
}, [videoId]);
|
||||
|
||||
// Check for saved progress and show resume prompt (only once on mount)
|
||||
useEffect(() => {
|
||||
if (!hasShownResumePromptRef.current && !progressLoading && hasProgress && progress && progress.last_position > 5) {
|
||||
setShowResumePrompt(true);
|
||||
setResumeTime(progress.last_position);
|
||||
hasShownResumePromptRef.current = true;
|
||||
}
|
||||
}, [progressLoading, hasProgress, progress]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -21,6 +21,15 @@ export function ChaptersEditor({ chapters, onChange, className = '' }: ChaptersE
|
||||
chapters.length > 0 ? chapters : [{ time: 0, title: '' }]
|
||||
);
|
||||
|
||||
// Sync internal state when prop changes (e.g., when switching between lessons)
|
||||
useEffect(() => {
|
||||
if (chapters.length > 0) {
|
||||
setChaptersList(chapters);
|
||||
} else {
|
||||
setChaptersList([{ time: 0, title: '' }]);
|
||||
}
|
||||
}, [chapters]);
|
||||
|
||||
const updateTime = (index: number, value: string) => {
|
||||
const newChapters = [...chaptersList];
|
||||
const parts = value.split(':').map(Number);
|
||||
|
||||
@@ -194,6 +194,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
mp4_url: '',
|
||||
video_host: 'youtube',
|
||||
release_at: '',
|
||||
chapters: [],
|
||||
});
|
||||
setLessonDialogOpen(true);
|
||||
};
|
||||
@@ -211,7 +212,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
mp4_url: lesson.mp4_url || '',
|
||||
video_host: lesson.video_host || 'youtube',
|
||||
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
||||
chapters: lesson.chapters || [],
|
||||
chapters: lesson.chapters ? [...lesson.chapters] : [], // Create a copy to avoid mutation
|
||||
});
|
||||
setLessonDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -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<string | null>(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 <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||
|
||||
return (
|
||||
@@ -595,6 +629,54 @@ export function BrandingTab() {
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -20,14 +20,12 @@ interface IntegrationSettings {
|
||||
google_oauth_config?: string;
|
||||
integration_email_provider: string;
|
||||
integration_email_api_base_url: string;
|
||||
integration_email_api_token: string;
|
||||
integration_email_from_name: string;
|
||||
integration_email_from_email: string;
|
||||
integration_privacy_url: string;
|
||||
integration_terms_url: string;
|
||||
integration_n8n_test_mode: boolean;
|
||||
// Mailketing specific settings
|
||||
provider: 'mailketing' | 'smtp';
|
||||
api_token: string;
|
||||
from_name: string;
|
||||
from_email: string;
|
||||
}
|
||||
|
||||
const emptySettings: IntegrationSettings = {
|
||||
@@ -37,13 +35,12 @@ const emptySettings: IntegrationSettings = {
|
||||
integration_google_calendar_id: '',
|
||||
integration_email_provider: 'mailketing',
|
||||
integration_email_api_base_url: '',
|
||||
integration_email_api_token: '',
|
||||
integration_email_from_name: '',
|
||||
integration_email_from_email: '',
|
||||
integration_privacy_url: '/privacy',
|
||||
integration_terms_url: '/terms',
|
||||
integration_n8n_test_mode: false,
|
||||
provider: 'mailketing',
|
||||
api_token: '',
|
||||
from_name: '',
|
||||
from_email: '',
|
||||
};
|
||||
|
||||
export function IntegrasiTab() {
|
||||
@@ -64,12 +61,6 @@ export function IntegrasiTab() {
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
// Fetch email provider settings from notification_settings
|
||||
const { data: emailData } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (platformData) {
|
||||
setSettings({
|
||||
id: platformData.id,
|
||||
@@ -80,14 +71,12 @@ export function IntegrasiTab() {
|
||||
google_oauth_config: platformData.google_oauth_config || '',
|
||||
integration_email_provider: platformData.integration_email_provider || 'mailketing',
|
||||
integration_email_api_base_url: platformData.integration_email_api_base_url || '',
|
||||
integration_email_api_token: platformData.integration_email_api_token || '',
|
||||
integration_email_from_name: platformData.integration_email_from_name || platformData.brand_email_from_name || '',
|
||||
integration_email_from_email: platformData.integration_email_from_email || '',
|
||||
integration_privacy_url: platformData.integration_privacy_url || '/privacy',
|
||||
integration_terms_url: platformData.integration_terms_url || '/terms',
|
||||
integration_n8n_test_mode: platformData.integration_n8n_test_mode || false,
|
||||
// Email settings from notification_settings
|
||||
provider: emailData?.provider || 'mailketing',
|
||||
api_token: emailData?.api_token || '',
|
||||
from_name: emailData?.from_name || platformData.brand_email_from_name || '',
|
||||
from_email: emailData?.from_email || '',
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -97,7 +86,7 @@ export function IntegrasiTab() {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Save platform settings
|
||||
// Save platform settings (includes email settings)
|
||||
const platformPayload = {
|
||||
integration_n8n_base_url: settings.integration_n8n_base_url,
|
||||
integration_whatsapp_number: settings.integration_whatsapp_number,
|
||||
@@ -106,6 +95,9 @@ export function IntegrasiTab() {
|
||||
google_oauth_config: settings.google_oauth_config,
|
||||
integration_email_provider: settings.integration_email_provider,
|
||||
integration_email_api_base_url: settings.integration_email_api_base_url,
|
||||
integration_email_api_token: settings.integration_email_api_token,
|
||||
integration_email_from_name: settings.integration_email_from_name,
|
||||
integration_email_from_email: settings.integration_email_from_email,
|
||||
integration_privacy_url: settings.integration_privacy_url,
|
||||
integration_terms_url: settings.integration_terms_url,
|
||||
integration_n8n_test_mode: settings.integration_n8n_test_mode,
|
||||
@@ -136,6 +128,9 @@ export function IntegrasiTab() {
|
||||
integration_google_calendar_id: settings.integration_google_calendar_id,
|
||||
integration_email_provider: settings.integration_email_provider,
|
||||
integration_email_api_base_url: settings.integration_email_api_base_url,
|
||||
integration_email_api_token: settings.integration_email_api_token,
|
||||
integration_email_from_name: settings.integration_email_from_name,
|
||||
integration_email_from_email: settings.integration_email_from_email,
|
||||
integration_privacy_url: settings.integration_privacy_url,
|
||||
integration_terms_url: settings.integration_terms_url,
|
||||
integration_n8n_test_mode: settings.integration_n8n_test_mode,
|
||||
@@ -153,34 +148,6 @@ export function IntegrasiTab() {
|
||||
}
|
||||
}
|
||||
|
||||
// Save email provider settings to notification_settings
|
||||
const emailPayload = {
|
||||
provider: settings.provider,
|
||||
api_token: settings.api_token,
|
||||
from_name: settings.from_name,
|
||||
from_email: settings.from_email,
|
||||
};
|
||||
|
||||
const { data: existingEmailSettings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('id')
|
||||
.maybeSingle();
|
||||
|
||||
if (existingEmailSettings?.id) {
|
||||
const { error: emailError } = await supabase
|
||||
.from('notification_settings')
|
||||
.update(emailPayload)
|
||||
.eq('id', existingEmailSettings.id);
|
||||
|
||||
if (emailError) throw emailError;
|
||||
} else {
|
||||
const { error: emailError } = await supabase
|
||||
.from('notification_settings')
|
||||
.insert(emailPayload);
|
||||
|
||||
if (emailError) throw emailError;
|
||||
}
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' });
|
||||
} catch (error: any) {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
@@ -195,21 +162,50 @@ export function IntegrasiTab() {
|
||||
|
||||
setSendingTest(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('send-email-v2', {
|
||||
// Get brand name for test email
|
||||
const { data: platformData } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('brand_name')
|
||||
.single();
|
||||
|
||||
const brandName = platformData?.brand_name || 'ACCESS HUB';
|
||||
|
||||
// Test email content using proper HTML template
|
||||
const testEmailContent = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #333;">Email Test - ${brandName}</h2>
|
||||
|
||||
<p>Halo,</p>
|
||||
|
||||
<p>Ini adalah email tes dari sistem <strong>${brandName}</strong>.</p>
|
||||
|
||||
<div style="margin: 20px 0; padding: 15px; background-color: #f0f0f0; border-left: 4px solid #000;">
|
||||
<p style="margin: 0; font-size: 14px;">
|
||||
<strong>✓ Konfigurasi email berhasil!</strong><br>
|
||||
Email Anda telah terkirim dengan benar menggunakan provider: <strong>Mailketing</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
Jika Anda menerima email ini, berarti konfigurasi email sudah benar.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px;">
|
||||
Terima kasih,<br>
|
||||
Tim ${brandName}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('send-notification', {
|
||||
body: {
|
||||
recipient: testEmail,
|
||||
api_token: settings.api_token,
|
||||
from_name: settings.from_name,
|
||||
from_email: settings.from_email,
|
||||
subject: 'Test Email dari Access Hub',
|
||||
content: `
|
||||
<h2>Test Email</h2>
|
||||
<p>Ini adalah email uji coba dari aplikasi Access Hub Anda.</p>
|
||||
<p>Jika Anda menerima email ini, konfigurasi Mailketing API sudah berfungsi dengan baik!</p>
|
||||
<p>Kirim ke: ${testEmail}</p>
|
||||
<br>
|
||||
<p>Best regards,<br>Access Hub Team</p>
|
||||
`,
|
||||
template_key: 'test_email',
|
||||
recipient_email: testEmail,
|
||||
recipient_name: 'Admin',
|
||||
variables: {
|
||||
brand_name: brandName,
|
||||
test_email: testEmail
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -228,7 +224,7 @@ export function IntegrasiTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const isEmailConfigured = settings.api_token && settings.from_email;
|
||||
const isEmailConfigured = settings.integration_email_api_token && settings.integration_email_from_email;
|
||||
|
||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||
|
||||
@@ -437,20 +433,19 @@ export function IntegrasiTab() {
|
||||
<div className="space-y-2">
|
||||
<Label>Provider Email</Label>
|
||||
<Select
|
||||
value={settings.provider}
|
||||
onValueChange={(value: 'mailketing' | 'smtp') => setSettings({ ...settings, provider: value })}
|
||||
value={settings.integration_email_provider}
|
||||
onValueChange={(value: 'mailketing') => setSettings({ ...settings, integration_email_provider: value })}
|
||||
>
|
||||
<SelectTrigger className="border-2">
|
||||
<SelectValue placeholder="Pilih provider email" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mailketing">Mailketing</SelectItem>
|
||||
<SelectItem value="smtp">SMTP (Legacy)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{settings.provider === 'mailketing' && (
|
||||
{settings.integration_email_provider === 'mailketing' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -459,8 +454,8 @@ export function IntegrasiTab() {
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.api_token}
|
||||
onChange={(e) => setSettings({ ...settings, api_token: e.target.value })}
|
||||
value={settings.integration_email_api_token}
|
||||
onChange={(e) => setSettings({ ...settings, integration_email_api_token: e.target.value })}
|
||||
placeholder="Masukkan API token dari Mailketing"
|
||||
className="border-2"
|
||||
/>
|
||||
@@ -473,8 +468,8 @@ export function IntegrasiTab() {
|
||||
<div className="space-y-2">
|
||||
<Label>Nama Pengirim</Label>
|
||||
<Input
|
||||
value={settings.from_name}
|
||||
onChange={(e) => setSettings({ ...settings, from_name: e.target.value })}
|
||||
value={settings.integration_email_from_name}
|
||||
onChange={(e) => setSettings({ ...settings, integration_email_from_name: e.target.value })}
|
||||
placeholder="Nama Bisnis"
|
||||
className="border-2"
|
||||
/>
|
||||
@@ -483,8 +478,8 @@ export function IntegrasiTab() {
|
||||
<Label>Email Pengirim</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={settings.from_email}
|
||||
onChange={(e) => setSettings({ ...settings, from_email: e.target.value })}
|
||||
value={settings.integration_email_from_email}
|
||||
onChange={(e) => setSettings({ ...settings, integration_email_from_email: e.target.value })}
|
||||
placeholder="info@domain.com"
|
||||
className="border-2"
|
||||
/>
|
||||
@@ -509,21 +504,6 @@ export function IntegrasiTab() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{settings.provider === 'smtp' && (
|
||||
<div className="space-y-2">
|
||||
<Label>API Base URL Provider Email</Label>
|
||||
<Input
|
||||
value={settings.integration_email_api_base_url}
|
||||
onChange={(e) => setSettings({ ...settings, integration_email_api_base_url: e.target.value })}
|
||||
placeholder="https://api.resend.com"
|
||||
className="border-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Konfigurasi SMTP masih di bagian Notifikasi
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface NotificationTemplate {
|
||||
const RELEVANT_SHORTCODES = {
|
||||
'payment_success': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{produk}', '{link_akses}', '{thank_you_page}'],
|
||||
'access_granted': ['{nama}', '{email}', '{produk}', '{link_akses}', '{username_akses}', '{password_akses}', '{kadaluarsa_akses}'],
|
||||
'order_created': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{produk}', '{payment_link}', '{thank_you_page}'],
|
||||
'order_created': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{produk}', '{payment_link}', '{thank_you_page}', '{qr_code_image}', '{qr_expiry_time}'],
|
||||
'payment_reminder': ['{nama}', '{email}', '{order_id}', '{tanggal_pesanan}', '{total}', '{metode_pembayaran}', '{batas_pembayaran}', '{jumlah_pembayaran}', '{bank_tujuan}', '{nomor_rekening}', '{payment_link}', '{thank_you_page}'],
|
||||
'consulting_scheduled': ['{nama}', '{email}', '{tanggal_konsultasi}', '{jam_konsultasi}', '{durasi_konsultasi}', '{link_meet}', '{jenis_konsultasi}', '{topik_konsultasi}'],
|
||||
'event_reminder': ['{nama}', '{email}', '{judul_event}', '{tanggal_event}', '{jam_event}', '{link_event}', '{lokasi_event}', '{kapasitas_event}'],
|
||||
@@ -143,6 +143,16 @@ const DEFAULT_TEMPLATES: { key: string; name: string; defaultSubject: string; de
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0; padding: 20px; background-color: #f5f5f5; border-radius: 8px;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; color: #333;">Scan QR untuk Pembayaran</h3>
|
||||
<img src="{qr_code_image}" alt="QRIS Payment QR Code" style="width: 200px; height: 200px; border: 2px solid #000; padding: 10px; background-color: #fff; display: inline-block;">
|
||||
<p style="margin: 15px 0 5px 0; font-size: 14px; color: #666;">Scan dengan aplikasi e-wallet atau mobile banking Anda</p>
|
||||
<p style="margin: 5px 0 0 0; font-size: 12px; color: #999;">Berlaku hingga: {qr_expiry_time}</p>
|
||||
<div style="margin-top: 15px;">
|
||||
<a href="{payment_link}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">Bayar Sekarang</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Langkah Selanjutnya:</h3>
|
||||
<ol>
|
||||
<li>Selesaikan pembayaran sebelum batas waktu</li>
|
||||
@@ -484,37 +494,30 @@ export function NotifikasiTab() {
|
||||
|
||||
setTestingTemplate(template.id);
|
||||
try {
|
||||
// Fetch email settings from notification_settings
|
||||
const { data: emailData } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
// Fetch platform settings to get brand name
|
||||
const { data: platformData } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('brand_name')
|
||||
.single();
|
||||
|
||||
if (!emailData || !emailData.api_token || !emailData.from_email) {
|
||||
throw new Error('Konfigurasi email provider belum lengkap');
|
||||
}
|
||||
const brandName = platformData?.brand_name || 'ACCESS HUB';
|
||||
|
||||
// Import EmailTemplateRenderer and ShortcodeProcessor
|
||||
const { EmailTemplateRenderer, ShortcodeProcessor } = await import('@/lib/email-templates/master-template');
|
||||
// Import ShortcodeProcessor to get dummy data
|
||||
const { ShortcodeProcessor } = await import('@/lib/email-templates/master-template');
|
||||
|
||||
// Process shortcodes and render with master template
|
||||
const processedSubject = ShortcodeProcessor.process(template.email_subject || '');
|
||||
const processedContent = ShortcodeProcessor.process(template.email_body_html || '');
|
||||
const fullHtml = EmailTemplateRenderer.render({
|
||||
subject: processedSubject,
|
||||
content: processedContent,
|
||||
brandName: 'ACCESS HUB'
|
||||
});
|
||||
// Get default dummy data for all template variables
|
||||
const dummyData = ShortcodeProcessor.getDummyData();
|
||||
|
||||
// Send test email using send-email-v2
|
||||
const { data, error } = await supabase.functions.invoke('send-email-v2', {
|
||||
// Send test email using send-notification (same as IntegrasiTab)
|
||||
const { data, error } = await supabase.functions.invoke('send-notification', {
|
||||
body: {
|
||||
recipient: template.test_email,
|
||||
api_token: emailData.api_token,
|
||||
from_name: emailData.from_name,
|
||||
from_email: emailData.from_email,
|
||||
subject: processedSubject,
|
||||
content: fullHtml,
|
||||
template_key: template.key,
|
||||
recipient_email: template.test_email,
|
||||
recipient_name: dummyData.nama,
|
||||
variables: {
|
||||
...dummyData,
|
||||
platform_name: brandName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// No session, set loading to false immediately
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}).catch((error: Error | unknown) => {
|
||||
// Catch CORS errors or other initialization errors
|
||||
console.error('Auth initialization error:', error);
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
|
||||
// Then listen for auth state changes
|
||||
@@ -107,37 +111,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const sendAuthOTP = async (userId: string, email: string) => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token || import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
const { data, error } = await supabase.functions.invoke('send-auth-otp', {
|
||||
body: { user_id: userId, email }
|
||||
});
|
||||
|
||||
console.log('Sending OTP request', { userId, email, hasSession: !!session });
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/send-auth-otp`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId, email }),
|
||||
}
|
||||
);
|
||||
|
||||
console.log('OTP response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('OTP request failed:', response.status, errorText);
|
||||
if (error) {
|
||||
console.error('OTP request error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `HTTP ${response.status}: ${errorText}`
|
||||
message: error.message || 'Failed to send OTP'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('OTP result:', result);
|
||||
return result;
|
||||
console.log('OTP result:', data);
|
||||
return {
|
||||
success: data?.success || false,
|
||||
message: data?.message || 'OTP sent successfully'
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Error sending OTP:', error);
|
||||
return {
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -382,4 +382,14 @@ All colors MUST be HSL.
|
||||
.prose img {
|
||||
@apply rounded-lg my-4;
|
||||
}
|
||||
|
||||
/* Timeline chapter inline code styling */
|
||||
.prose-sm code:not(pre code) {
|
||||
@apply bg-slate-100 text-slate-800 px-1 py-0.5 rounded text-xs font-mono;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.flex > .flex-1 > code {
|
||||
background-color: #dedede;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -23,14 +23,24 @@ export default function Auth() {
|
||||
const [pendingUserId, setPendingUserId] = useState<string | null>(null);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
const [isResendOTP, setIsResendOTP] = useState(false); // Track if this is resend OTP for existing user
|
||||
const { signIn, signUp, user, sendAuthOTP, verifyAuthOTP, getUserByEmail } = useAuth();
|
||||
const { signIn, signUp, user, isAdmin, sendAuthOTP, verifyAuthOTP, getUserByEmail } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
navigate('/dashboard');
|
||||
// Check if there's a saved redirect path
|
||||
const savedRedirect = sessionStorage.getItem('redirectAfterLogin');
|
||||
if (savedRedirect) {
|
||||
sessionStorage.removeItem('redirectAfterLogin');
|
||||
navigate(savedRedirect);
|
||||
} else {
|
||||
// Default redirect based on user role (use isAdmin flag from context)
|
||||
const defaultRedirect = isAdmin ? '/admin' : '/dashboard';
|
||||
navigate(defaultRedirect);
|
||||
}
|
||||
}, [user, navigate]);
|
||||
}
|
||||
}, [user, isAdmin, navigate]);
|
||||
|
||||
// Countdown timer for resend OTP
|
||||
useEffect(() => {
|
||||
@@ -100,7 +110,8 @@ export default function Auth() {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
setLoading(false);
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
// Login successful - the useEffect watching 'user' will handle the redirect
|
||||
// This ensures we have the full user metadata including role
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -64,6 +64,176 @@ interface UserReview {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Helper function to get YouTube embed URL
|
||||
const getYouTubeEmbedUrl = (url: string): string => {
|
||||
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
||||
};
|
||||
|
||||
// Move VideoPlayer component outside main component to prevent re-creation on every render
|
||||
const VideoPlayer = ({
|
||||
lesson,
|
||||
playerRef,
|
||||
currentTime,
|
||||
accentColor,
|
||||
setCurrentTime
|
||||
}: {
|
||||
lesson: Lesson;
|
||||
playerRef: React.RefObject<VideoPlayerRef>;
|
||||
currentTime: number;
|
||||
accentColor: string;
|
||||
setCurrentTime: (time: number) => void;
|
||||
}) => {
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||
|
||||
// Get video based on lesson's video_host (prioritize Adilo)
|
||||
const getVideoSource = () => {
|
||||
// If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority.
|
||||
const lessonVideoHost = lesson.video_host || (
|
||||
lesson.m3u8_url ? 'adilo' :
|
||||
lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' :
|
||||
lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' :
|
||||
'unknown'
|
||||
);
|
||||
|
||||
if (lessonVideoHost === 'adilo') {
|
||||
// Adilo M3U8 streaming
|
||||
if (lesson.m3u8_url && lesson.m3u8_url.trim()) {
|
||||
return {
|
||||
type: 'adilo',
|
||||
m3u8Url: lesson.m3u8_url,
|
||||
mp4Url: lesson.mp4_url || undefined,
|
||||
videoHost: 'adilo'
|
||||
};
|
||||
} else if (lesson.mp4_url && lesson.mp4_url.trim()) {
|
||||
// Fallback to MP4 only
|
||||
return {
|
||||
type: 'adilo',
|
||||
mp4Url: lesson.mp4_url,
|
||||
videoHost: 'adilo'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// YouTube or fallback
|
||||
if (lessonVideoHost === 'youtube') {
|
||||
if (lesson.youtube_url && lesson.youtube_url.trim()) {
|
||||
return {
|
||||
type: 'youtube',
|
||||
url: lesson.youtube_url,
|
||||
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
|
||||
};
|
||||
} else if (lesson.video_url && lesson.video_url.trim()) {
|
||||
// Fallback to old video_url for backward compatibility
|
||||
return {
|
||||
type: 'youtube',
|
||||
url: lesson.video_url,
|
||||
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: try embed code
|
||||
return lesson.embed_code && lesson.embed_code.trim() ? {
|
||||
type: 'embed',
|
||||
html: lesson.embed_code
|
||||
} : null;
|
||||
};
|
||||
|
||||
// Memoize video source to prevent unnecessary re-renders
|
||||
const video = useMemo(getVideoSource, [lesson.id, lesson.video_host, lesson.m3u8_url, lesson.mp4_url, lesson.youtube_url, lesson.video_url, lesson.embed_code]);
|
||||
|
||||
// Determine video type - must be computed before conditional returns
|
||||
const isYouTube = video?.type === 'youtube';
|
||||
const isAdilo = video?.type === 'adilo';
|
||||
const isEmbed = video?.type === 'embed';
|
||||
|
||||
// Memoize URL values BEFORE any conditional returns (Rules of Hooks)
|
||||
const videoUrl = useMemo(() => (isYouTube ? video?.url : undefined), [isYouTube, video?.url]);
|
||||
const m3u8Url = useMemo(() => (isAdilo ? video?.m3u8Url : undefined), [isAdilo, video?.m3u8Url]);
|
||||
const mp4Url = useMemo(() => (isAdilo ? video?.mp4Url : undefined), [isAdilo, video?.mp4Url]);
|
||||
|
||||
// Show warning if no video available
|
||||
if (!video) {
|
||||
return (
|
||||
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-destructive font-medium">Konten tidak tersedia</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Render based on video type
|
||||
if (isEmbed) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||
<div dangerouslySetInnerHTML={{ __html: video.html }} />
|
||||
</div>
|
||||
{hasChapters && (
|
||||
<div className="mt-4">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Video Player - Full Width */}
|
||||
<div className="mb-6">
|
||||
<VideoPlayerWithChapters
|
||||
ref={playerRef}
|
||||
videoUrl={videoUrl}
|
||||
m3u8Url={m3u8Url}
|
||||
mp4Url={mp4Url}
|
||||
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
|
||||
chapters={lesson.chapters}
|
||||
accentColor={accentColor}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
videoId={lesson.id}
|
||||
videoType="lesson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeline Chapters - Below video like WebinarRecording */}
|
||||
{hasChapters && (
|
||||
<div className="mb-6">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
onChapterClick={(time) => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.jumpToTime(time);
|
||||
}
|
||||
}}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Bootcamp() {
|
||||
const { slug, lessonId } = useParams<{ slug: string; lessonId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -237,6 +407,7 @@ export default function Bootcamp() {
|
||||
|
||||
// Calculate completion percentage for notification
|
||||
const completedCount = newProgress.length;
|
||||
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||
const completionPercent = Math.round((completedCount / totalLessons) * 100);
|
||||
|
||||
// Trigger progress notification at milestones
|
||||
@@ -282,141 +453,6 @@ export default function Bootcamp() {
|
||||
}
|
||||
};
|
||||
|
||||
const getYouTubeEmbedUrl = (url: string): string => {
|
||||
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
||||
};
|
||||
|
||||
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
||||
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||
|
||||
// Get video based on lesson's video_host (prioritize Adilo)
|
||||
const getVideoSource = () => {
|
||||
// If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority.
|
||||
const lessonVideoHost = lesson.video_host || (
|
||||
lesson.m3u8_url ? 'adilo' :
|
||||
lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' :
|
||||
lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' :
|
||||
'unknown'
|
||||
);
|
||||
|
||||
if (lessonVideoHost === 'adilo') {
|
||||
// Adilo M3U8 streaming
|
||||
if (lesson.m3u8_url && lesson.m3u8_url.trim()) {
|
||||
return {
|
||||
type: 'adilo',
|
||||
m3u8Url: lesson.m3u8_url,
|
||||
mp4Url: lesson.mp4_url || undefined,
|
||||
videoHost: 'adilo'
|
||||
};
|
||||
} else if (lesson.mp4_url && lesson.mp4_url.trim()) {
|
||||
// Fallback to MP4 only
|
||||
return {
|
||||
type: 'adilo',
|
||||
mp4Url: lesson.mp4_url,
|
||||
videoHost: 'adilo'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// YouTube or fallback
|
||||
if (lessonVideoHost === 'youtube') {
|
||||
if (lesson.youtube_url && lesson.youtube_url.trim()) {
|
||||
return {
|
||||
type: 'youtube',
|
||||
url: lesson.youtube_url,
|
||||
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
|
||||
};
|
||||
} else if (lesson.video_url && lesson.video_url.trim()) {
|
||||
// Fallback to old video_url for backward compatibility
|
||||
return {
|
||||
type: 'youtube',
|
||||
url: lesson.video_url,
|
||||
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: try embed code
|
||||
return lesson.embed_code && lesson.embed_code.trim() ? {
|
||||
type: 'embed',
|
||||
html: lesson.embed_code
|
||||
} : null;
|
||||
};
|
||||
|
||||
const video = getVideoSource();
|
||||
|
||||
// Show warning if no video available
|
||||
if (!video) {
|
||||
return (
|
||||
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-destructive font-medium">Konten tidak tersedia</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Render based on video type
|
||||
if (video.type === 'embed') {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||
<div dangerouslySetInnerHTML={{ __html: video.html }} />
|
||||
</div>
|
||||
{hasChapters && (
|
||||
<div className="mt-4">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Adilo or YouTube with chapters support
|
||||
const isYouTube = video.type === 'youtube';
|
||||
const isAdilo = video.type === 'adilo';
|
||||
|
||||
return (
|
||||
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
|
||||
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
||||
<VideoPlayerWithChapters
|
||||
ref={playerRef}
|
||||
videoUrl={isYouTube ? video.url : undefined}
|
||||
m3u8Url={isAdilo ? video.m3u8Url : undefined}
|
||||
mp4Url={isAdilo ? video.mp4Url : undefined}
|
||||
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
|
||||
chapters={lesson.chapters}
|
||||
accentColor={accentColor}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
videoId={lesson.id}
|
||||
videoType="lesson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasChapters && (
|
||||
<div className="lg:col-span-1">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
onChapterClick={(time) => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.jumpToTime(time);
|
||||
}
|
||||
}}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const completedCount = progress.length;
|
||||
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
||||
@@ -428,7 +464,7 @@ export default function Bootcamp() {
|
||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{module.title}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 ml-2">
|
||||
{module.lessons.map((lesson) => {
|
||||
const isCompleted = isLessonCompleted(lesson.id);
|
||||
const isSelected = selectedLesson?.id === lesson.id;
|
||||
@@ -561,7 +597,13 @@ export default function Bootcamp() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VideoPlayer lesson={selectedLesson} />
|
||||
<VideoPlayer
|
||||
lesson={selectedLesson}
|
||||
playerRef={playerRef}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
setCurrentTime={setCurrentTime}
|
||||
/>
|
||||
|
||||
{selectedLesson.content && (
|
||||
<Card className="border-2 border-border mb-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AppLayout } from "@/components/AppLayout";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
@@ -6,9 +6,13 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { formatIDR } from "@/lib/format";
|
||||
import { Trash2, CreditCard, Loader2, QrCode } from "lucide-react";
|
||||
import { Trash2, CreditCard, Loader2, QrCode, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
// Edge function base URL - configurable via env with sensible default
|
||||
const getEdgeFunctionBaseUrl = (): string => {
|
||||
@@ -21,12 +25,23 @@ type CheckoutStep = "cart" | "payment";
|
||||
|
||||
export default function Checkout() {
|
||||
const { items, removeItem, clearCart, total } = useCart();
|
||||
const { user } = useAuth();
|
||||
const { user, signIn, signUp, sendAuthOTP, verifyAuthOTP } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [step, setStep] = useState<CheckoutStep>("cart");
|
||||
|
||||
// Auth modal state
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [authEmail, setAuthEmail] = useState("");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [authName, setAuthName] = useState("");
|
||||
const [showOTP, setShowOTP] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [pendingUserId, setPendingUserId] = useState<string | null>(null);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
const checkPaymentStatus = async (oid: string) => {
|
||||
const { data: order } = await supabase.from("orders").select("payment_status").eq("id", oid).single();
|
||||
|
||||
@@ -39,7 +54,8 @@ export default function Checkout() {
|
||||
const handleCheckout = async () => {
|
||||
if (!user) {
|
||||
toast({ title: "Login diperlukan", description: "Silakan login untuk melanjutkan pembayaran" });
|
||||
navigate("/auth");
|
||||
// Pass current location for redirect after login
|
||||
navigate("/auth", { state: { redirectTo: window.location.pathname } });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,6 +105,42 @@ export default function Checkout() {
|
||||
const { error: itemsError } = await supabase.from("order_items").insert(orderItems);
|
||||
if (itemsError) throw new Error("Gagal menambahkan item order");
|
||||
|
||||
// Send order_created email IMMEDIATELY after order is created (before payment QR)
|
||||
console.log('[CHECKOUT] About to send order_created email for order:', order.id);
|
||||
console.log('[CHECKOUT] User email:', user.email);
|
||||
|
||||
try {
|
||||
const result = await supabase.functions.invoke('send-notification', {
|
||||
body: {
|
||||
template_key: 'order_created',
|
||||
recipient_email: user.email,
|
||||
recipient_name: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan',
|
||||
variables: {
|
||||
nama: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan',
|
||||
email: user.email,
|
||||
order_id: order.id,
|
||||
order_id_short: order.id.substring(0, 8),
|
||||
tanggal_pesanan: new Date().toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}),
|
||||
total: formatIDR(total),
|
||||
metode_pembayaran: 'QRIS',
|
||||
produk: items.map(item => item.title).join(', '),
|
||||
payment_link: `${window.location.origin}/orders/${order.id}`,
|
||||
thank_you_page: `${window.location.origin}/orders/${order.id}`
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('[CHECKOUT] send-notification called successfully:', result);
|
||||
} catch (emailErr) {
|
||||
console.error('[CHECKOUT] Failed to send order_created email:', emailErr);
|
||||
// Don't block checkout flow if email fails
|
||||
}
|
||||
|
||||
console.log('[CHECKOUT] Order creation email call completed');
|
||||
|
||||
// Build description from product titles
|
||||
const productTitles = items.map(item => item.title).join(", ");
|
||||
|
||||
@@ -127,6 +179,168 @@ export default function Checkout() {
|
||||
toast({ title: "Info", description: "Status pembayaran diupdate otomatis" });
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!authEmail || !authPassword) {
|
||||
toast({ title: "Error", description: "Email dan password wajib diisi", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
const { error } = await signIn(authEmail, authPassword);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Login gagal",
|
||||
description: error.message || "Email atau password salah",
|
||||
variant: "destructive",
|
||||
});
|
||||
setAuthLoading(false);
|
||||
} else {
|
||||
toast({ title: "Login berhasil", description: "Silakan lanjutkan pembayaran" });
|
||||
setAuthModalOpen(false);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!authEmail || !authPassword || !authName) {
|
||||
toast({ title: "Error", description: "Semua field wajib diisi", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (authPassword.length < 6) {
|
||||
toast({ title: "Error", description: "Password minimal 6 karakter", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
|
||||
try {
|
||||
const { data, error } = await signUp(authEmail, authPassword, authName);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Registrasi gagal",
|
||||
description: error.message || "Gagal membuat akun",
|
||||
variant: "destructive",
|
||||
});
|
||||
setAuthLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.user) {
|
||||
toast({ title: "Error", description: "Failed to create user account. Please try again.", variant: "destructive" });
|
||||
setAuthLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// User created, now send OTP
|
||||
const userId = data.user.id;
|
||||
const result = await sendAuthOTP(userId, authEmail);
|
||||
|
||||
if (result.success) {
|
||||
setPendingUserId(userId);
|
||||
setShowOTP(true);
|
||||
setResendCountdown(60);
|
||||
toast({
|
||||
title: "OTP Terkirim",
|
||||
description: "Kode verifikasi telah dikirim ke email Anda. Silakan cek inbox.",
|
||||
});
|
||||
} else {
|
||||
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: "Error", description: error.message || "Terjadi kesalahan", variant: "destructive" });
|
||||
}
|
||||
|
||||
setAuthLoading(false);
|
||||
};
|
||||
|
||||
const handleOTPSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!pendingUserId) {
|
||||
toast({ title: "Error", description: "Session expired. Please try again.", variant: "destructive" });
|
||||
setShowOTP(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (otpCode.length !== 6) {
|
||||
toast({ title: "Error", description: "OTP harus 6 digit", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
|
||||
try {
|
||||
const result = await verifyAuthOTP(pendingUserId, otpCode);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Verifikasi Berhasil",
|
||||
description: "Akun Anda telah terverifikasi. Mengalihkan...",
|
||||
});
|
||||
|
||||
// Auto-login after OTP verification
|
||||
const loginResult = await signIn(authEmail, authPassword);
|
||||
|
||||
if (loginResult.error) {
|
||||
toast({
|
||||
title: "Peringatan",
|
||||
description: "Akun terverifikasi tapi gagal login otomatis. Silakan login manual.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
|
||||
setShowOTP(false);
|
||||
setAuthModalOpen(false);
|
||||
// Reset form
|
||||
setAuthName("");
|
||||
setAuthEmail("");
|
||||
setAuthPassword("");
|
||||
setOtpCode("");
|
||||
setPendingUserId(null);
|
||||
} else {
|
||||
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: "Error", description: error.message || "Verifikasi gagal", variant: "destructive" });
|
||||
}
|
||||
|
||||
setAuthLoading(false);
|
||||
};
|
||||
|
||||
const handleResendOTP = async () => {
|
||||
if (resendCountdown > 0 || !pendingUserId) return;
|
||||
|
||||
setAuthLoading(true);
|
||||
|
||||
try {
|
||||
const result = await sendAuthOTP(pendingUserId, authEmail);
|
||||
|
||||
if (result.success) {
|
||||
setResendCountdown(60);
|
||||
toast({ title: "OTP Terkirim Ulang", description: "Kode verifikasi baru telah dikirim ke email Anda." });
|
||||
} else {
|
||||
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: "Error", description: error.message || "Gagal mengirim OTP", variant: "destructive" });
|
||||
}
|
||||
|
||||
setAuthLoading(false);
|
||||
};
|
||||
|
||||
// Resend countdown timer
|
||||
useEffect(() => {
|
||||
if (resendCountdown > 0) {
|
||||
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [resendCountdown]);
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -192,21 +406,208 @@ export default function Checkout() {
|
||||
<span className="font-bold">{formatIDR(total)}</span>
|
||||
</div>
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
{user ? (
|
||||
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : user ? (
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Bayar dengan QRIS
|
||||
</>
|
||||
) : (
|
||||
"Login untuk Checkout"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Dialog open={authModalOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Reset state when closing
|
||||
setShowOTP(false);
|
||||
setOtpCode("");
|
||||
setPendingUserId(null);
|
||||
setResendCountdown(0);
|
||||
}
|
||||
setAuthModalOpen(open);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full shadow-sm">
|
||||
Login atau Daftar untuk Checkout
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md" onPointerDownOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{showOTP ? "Verifikasi Email" : "Login atau Daftar"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!showOTP ? (
|
||||
<Tabs defaultValue="login" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="login">Login</TabsTrigger>
|
||||
<TabsTrigger value="register">Daftar</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="login">
|
||||
<form onSubmit={handleLogin} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="login-email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="nama@email.com"
|
||||
value={authEmail}
|
||||
onChange={(e) => setAuthEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="login-password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={authLoading}>
|
||||
{authLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
<TabsContent value="register">
|
||||
<form onSubmit={handleRegister} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="register-name" className="text-sm font-medium">
|
||||
Nama Lengkap
|
||||
</label>
|
||||
<Input
|
||||
id="register-name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={authName}
|
||||
onChange={(e) => setAuthName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="register-email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="register-email"
|
||||
type="email"
|
||||
placeholder="nama@email.com"
|
||||
value={authEmail}
|
||||
onChange={(e) => setAuthEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="register-password" className="text-sm font-medium">
|
||||
Password (minimal 6 karakter)
|
||||
</label>
|
||||
<Input
|
||||
id="register-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={authLoading}>
|
||||
{authLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : (
|
||||
"Daftar"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<form onSubmit={handleOTPSubmit} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Masukkan kode 6 digit yang telah dikirim ke <strong>{authEmail}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="otp-code" className="text-sm font-medium">
|
||||
Kode Verifikasi
|
||||
</label>
|
||||
<Input
|
||||
id="otp-code"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
maxLength={6}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={authLoading || otpCode.length !== 6}>
|
||||
{authLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memverifikasi...
|
||||
</>
|
||||
) : (
|
||||
"Verifikasi"
|
||||
)}
|
||||
</Button>
|
||||
<div className="text-center space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendOTP}
|
||||
disabled={resendCountdown > 0 || authLoading}
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{resendCountdown > 0
|
||||
? `Kirim ulang dalam ${resendCountdown} detik`
|
||||
: "Belum menerima kode? Kirim ulang"}
|
||||
</button>
|
||||
{pendingUserId && authEmail && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Modal tertutup tidak sengaja?{" "}
|
||||
<a
|
||||
href={`/confirm-otp?user_id=${pendingUserId}&email=${encodeURIComponent(authEmail)}`}
|
||||
className="text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowOTP(false);
|
||||
setAuthModalOpen(false);
|
||||
window.location.href = `/confirm-otp?user_id=${pendingUserId}&email=${encodeURIComponent(authEmail)}`;
|
||||
}}
|
||||
>
|
||||
Buka halaman verifikasi khusus
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground text-center">Pembayaran aman dengan standar QRIS dari Bank Indonesia</p>
|
||||
<p className="text-xs text-muted-foreground text-center">Diproses oleh mitra pembayaran terpercaya</p>
|
||||
|
||||
255
src/pages/ConfirmOTP.tsx
Normal file
255
src/pages/ConfirmOTP.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { AppLayout } from "@/components/AppLayout";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { Loader2, ArrowLeft, Mail } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function ConfirmOTP() {
|
||||
const { user, signIn, sendAuthOTP, verifyAuthOTP } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
// Get user_id and email from URL params or from user state
|
||||
const userId = searchParams.get('user_id') || user?.id;
|
||||
const email = searchParams.get('email') || user?.email;
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId && !user) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Sesi tidak valid. Silakan mendaftar ulang.",
|
||||
variant: "destructive"
|
||||
});
|
||||
navigate('/auth');
|
||||
}
|
||||
}, [userId, user]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!userId) {
|
||||
toast({ title: "Error", description: "Session expired. Please try again.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (otpCode.length !== 6) {
|
||||
toast({ title: "Error", description: "OTP harus 6 digit", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await verifyAuthOTP(userId, otpCode);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Verifikasi Berhasil",
|
||||
description: "Akun Anda telah terverifikasi. Mengalihkan...",
|
||||
});
|
||||
|
||||
// If user is already logged in, just redirect
|
||||
if (user) {
|
||||
setTimeout(() => {
|
||||
navigate('/dashboard');
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get email from URL params or use a default
|
||||
const userEmail = email || searchParams.get('email');
|
||||
|
||||
if (userEmail) {
|
||||
// Auto-login after OTP verification
|
||||
// We need the password, which should have been stored or we need to ask user
|
||||
// For now, redirect to login with success message
|
||||
setTimeout(() => {
|
||||
navigate('/auth', {
|
||||
state: {
|
||||
message: "Email berhasil diverifikasi. Silakan login dengan email dan password Anda.",
|
||||
email: userEmail
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
navigate('/auth', {
|
||||
state: {
|
||||
message: "Email berhasil diverifikasi. Silakan login."
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: "Error", description: error.message || "Verifikasi gagal", variant: "destructive" });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleResendOTP = async () => {
|
||||
if (resendCountdown > 0 || !userId || !email) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await sendAuthOTP(userId, email);
|
||||
|
||||
if (result.success) {
|
||||
setResendCountdown(60);
|
||||
toast({ title: "OTP Terkirim Ulang", description: "Kode verifikasi baru telah dikirim ke email Anda." });
|
||||
} else {
|
||||
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: "Error", description: error.message || "Gagal mengirim OTP", variant: "destructive" });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Resend countdown timer
|
||||
useEffect(() => {
|
||||
if (resendCountdown > 0) {
|
||||
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [resendCountdown]);
|
||||
|
||||
if (!userId) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="max-w-md mx-auto border-2 border-border">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Sesi tidak valid atau telah kedaluwarsa.</p>
|
||||
<Link to="/auth">
|
||||
<Button variant="outline" className="mt-4 border-2">
|
||||
Kembali ke Halaman Auth
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
{/* Back Button */}
|
||||
<Link to="/auth">
|
||||
<Button variant="ghost" className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Kembali ke Login
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Card */}
|
||||
<Card className="border-2 border-border shadow-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Mail className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Konfirmasi Email</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Masukkan kode 6 digit yang telah dikirim ke <strong>{email}</strong>
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="otp-code" className="text-sm font-medium">
|
||||
Kode Verifikasi
|
||||
</label>
|
||||
<Input
|
||||
id="otp-code"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
maxLength={6}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memverifikasi...
|
||||
</>
|
||||
) : (
|
||||
"Verifikasi Email"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendOTP}
|
||||
disabled={resendCountdown > 0 || loading}
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{resendCountdown > 0
|
||||
? `Kirim ulang dalam ${resendCountdown} detik`
|
||||
: "Belum menerima kode? Kirim ulang"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-xs text-center text-muted-foreground space-y-1">
|
||||
<p>💡 <strong>Tips:</strong> Kode berlaku selama 15 menit.</p>
|
||||
<p>Cek folder spam jika email tidak muncul di inbox.</p>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Help Box */}
|
||||
<Card className="border-2 border-border bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm space-y-2">
|
||||
<p className="font-medium">Tidak menerima email?</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||
<li>Pastikan email yang dimasukkan benar</li>
|
||||
<li>Cek folder spam/junk email</li>
|
||||
<li>Tunggu beberapa saat, email mungkin memerlukan waktu untuk sampai</li>
|
||||
</ul>
|
||||
{email && (
|
||||
<p className="mt-2">
|
||||
Belum mendaftar?{" "}
|
||||
<Link to="/auth" className="text-primary hover:underline font-medium">
|
||||
Kembali ke pendaftaran
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -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 } 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 {
|
||||
@@ -68,10 +72,13 @@ export default function ProductDetail() {
|
||||
const [hasAccess, setHasAccess] = useState(false);
|
||||
const [checkingAccess, setCheckingAccess] = useState(true);
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
||||
const [expandedLessonChapters, setExpandedLessonChapters] = useState<Set<string>>(new Set());
|
||||
const [userReview, setUserReview] = useState<UserReview | null>(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();
|
||||
@@ -92,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')
|
||||
@@ -138,6 +167,9 @@ export default function ProductDetail() {
|
||||
if (sorted.length > 0) {
|
||||
setExpandedModules(new Set([sorted[0].id]));
|
||||
}
|
||||
|
||||
// Keep all lesson timelines collapsed by default for cleaner UX
|
||||
setExpandedLessonChapters(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,11 +254,21 @@ export default function ProductDetail() {
|
||||
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
||||
|
||||
const formatChapterTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const isLastTimelineItem = (length: number, chapterIndex: number)=> {
|
||||
const calcLength = length - 1;
|
||||
return calcLength !== chapterIndex;
|
||||
}
|
||||
|
||||
const renderWebinarChapters = () => {
|
||||
if (product?.type !== 'webinar' || !product.chapters || product.chapters.length === 0) return null;
|
||||
|
||||
@@ -238,18 +280,18 @@ export default function ProductDetail() {
|
||||
{product.chapters.map((chapter, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent transition-colors cursor-pointer group"
|
||||
onClick={() => product && navigate(`/webinar/${product.slug}`)}
|
||||
className="flex items-start gap-3 p-3 rounded-lg transition-colors cursor-not-allowed opacity-75"
|
||||
title="Beli webinar untuk mengakses konten ini"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 text-center">
|
||||
<span className="text-sm font-mono text-muted-foreground group-hover:text-primary">
|
||||
<span className="text-sm font-mono text-muted-foreground">
|
||||
{formatChapterTime(chapter.time)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{chapter.title}</p>
|
||||
</div>
|
||||
<Play className="w-4 h-4 text-muted-foreground group-hover:text-primary flex-shrink-0" />
|
||||
<Lock className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -278,6 +320,22 @@ export default function ProductDetail() {
|
||||
setExpandedModules(newSet);
|
||||
};
|
||||
|
||||
const toggleLessonChapters = (lessonId: string) => {
|
||||
const newSet = new Set(expandedLessonChapters);
|
||||
if (newSet.has(lessonId)) {
|
||||
newSet.delete(lessonId);
|
||||
} else {
|
||||
newSet.add(lessonId);
|
||||
}
|
||||
setExpandedLessonChapters(newSet);
|
||||
};
|
||||
|
||||
// Check if product has any recording (YouTube, M3U8, or MP4)
|
||||
const hasRecording = () => {
|
||||
if (!product) return false;
|
||||
return !!(product.recording_url || product.m3u8_url || product.mp4_url);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (<AppLayout><div className="container mx-auto px-4 py-8"><Skeleton className="h-10 w-1/2 mb-4" /><Skeleton className="h-6 w-1/4 mb-8" /><Skeleton className="h-64 w-full" /></div></AppLayout>);
|
||||
}
|
||||
@@ -308,7 +366,7 @@ export default function ProductDetail() {
|
||||
</Button>
|
||||
);
|
||||
case 'webinar':
|
||||
if (product.recording_url) {
|
||||
if (hasRecording()) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="border-2 border-primary/20 bg-primary/5">
|
||||
@@ -416,20 +474,39 @@ export default function ProductDetail() {
|
||||
|
||||
{/* Lesson chapters (if any) */}
|
||||
{lesson.chapters && lesson.chapters.length > 0 && (
|
||||
<Collapsible
|
||||
open={expandedLessonChapters.has(lesson.id)}
|
||||
onOpenChange={() => toggleLessonChapters(lesson.id)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 ml-5 mb-2 py-1 px-2 text-xs bg-muted text-muted-foreground hover:bg-accent rounded transition-colors w-full">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span className="flex-1 text-left">
|
||||
{lesson.chapters.length} timeline item{lesson.chapters.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
{expandedLessonChapters.has(lesson.id) ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="ml-5 space-y-1">
|
||||
{lesson.chapters.map((chapter, chapterIndex) => (
|
||||
<div
|
||||
key={chapterIndex}
|
||||
className="flex items-center gap-2 py-1 px-2 text-xs text-muted-foreground hover:bg-accent/50 rounded transition-colors cursor-pointer group"
|
||||
onClick={() => product && navigate(`/bootcamp/${product.slug}`)}
|
||||
className={`flex items-start gap-2 py-1 px-2 text-xs text-muted-foreground rounded transition-colors cursor-not-allowed opacity-60${isLastTimelineItem(lesson.chapters.length, chapterIndex) ? ' border-b-2 border-[#dedede] rounded-none' : ''}`}
|
||||
title="Beli bootcamp untuk mengakses materi ini"
|
||||
>
|
||||
<span className="font-mono w-10 text-center group-hover:text-primary">
|
||||
<span className="font-mono w-12 text-center">
|
||||
{formatChapterTime(chapter.time)}
|
||||
</span>
|
||||
<span className="flex-1 group-hover:text-foreground">{chapter.title}</span>
|
||||
<span className="flex-1" dangerouslySetInnerHTML={{ __html: chapter.title }} />
|
||||
<Lock className="w-3 h-3 flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -485,16 +562,44 @@ export default function ProductDetail() {
|
||||
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge>
|
||||
{product.type === 'webinar' && product.recording_url && (
|
||||
{product.collaborator_user_id && <Badge variant="secondary">Collab</Badge>}
|
||||
{product.type === 'webinar' && hasRecording() && (
|
||||
<Badge className="bg-secondary text-primary">Rekaman Tersedia</Badge>
|
||||
)}
|
||||
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) > new Date() && (
|
||||
{product.type === 'webinar' && !hasRecording() && product.event_start && new Date(product.event_start) > new Date() && (
|
||||
<Badge className="bg-brand-accent text-white">Segera Hadir</Badge>
|
||||
)}
|
||||
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && (
|
||||
{product.type === 'webinar' && !hasRecording() && product.event_start && new Date(product.event_start) <= new Date() && (
|
||||
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
||||
)}
|
||||
</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 className="text-right">
|
||||
{product.sale_price ? (
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
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<string>('all');
|
||||
const [collaborators, setCollaborators] = useState<Record<string, CollaboratorProfile>>({});
|
||||
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<Record<string, CollaboratorProfile>>((acc, row) => {
|
||||
acc[row.id] = row;
|
||||
return acc;
|
||||
}, {});
|
||||
setCollaborators(byId);
|
||||
}
|
||||
} else {
|
||||
setCollaborators({});
|
||||
}
|
||||
}
|
||||
|
||||
if (consultingRes.data) {
|
||||
@@ -105,7 +143,7 @@ export default function Products() {
|
||||
});
|
||||
|
||||
// Get unique product types for filter
|
||||
const productTypes = ['all', ...Array.from(new Set(products.map(p => p.type)))];
|
||||
const productTypes: string[] = ['all', ...Array.from(new Set(products.map(p => p.type as string)))];
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
@@ -118,21 +156,6 @@ export default function Products() {
|
||||
<h1 className="text-4xl font-bold mb-2">Produk</h1>
|
||||
<p className="text-muted-foreground mb-4">Jelajahi konsultasi, webinar, dan bootcamp kami</p>
|
||||
|
||||
{/* Consulting Availability Banner */}
|
||||
{!loading && consultingSettings?.is_consulting_enabled && (
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-2 border-primary/30 flex items-center gap-3 hover:border-primary/50 transition-colors">
|
||||
<div className="bg-primary text-primary-foreground p-2 rounded-full shrink-0">
|
||||
<Video className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Konsultasi Tersedia!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Booking jadwal konsultasi 1-on-1 dengan mentor • {formatIDR(consultingSettings.consulting_block_price)} / {consultingSettings.consulting_block_duration_minutes} menit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filter */}
|
||||
{!loading && products.length > 0 && (
|
||||
<div className="mb-6 space-y-4">
|
||||
@@ -143,7 +166,7 @@ export default function Products() {
|
||||
type="text"
|
||||
placeholder="Cari produk..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-2"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -218,7 +241,7 @@ export default function Products() {
|
||||
<Video className="w-5 h-5 text-primary shrink-0" />
|
||||
Konsultasi 1-on-1
|
||||
</CardTitle>
|
||||
<Badge className="bg-primary text-white shadow-sm shrink-0">
|
||||
<Badge variant="default" className="shrink-0">
|
||||
Konsultasi
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -243,14 +266,42 @@ export default function Products() {
|
||||
)}
|
||||
|
||||
{/* Regular Products */}
|
||||
{filteredProducts.map((product) => (
|
||||
{filteredProducts.map((product: Product) => (
|
||||
<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">
|
||||
<div className="flex justify-between items-start gap-2 mb-2">
|
||||
<CardTitle className="text-xl line-clamp-1">{product.title}</CardTitle>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{getTypeLabel(product.type)}
|
||||
</Badge>
|
||||
<CardTitle className="text-xl line-clamp-2 leading-tight min-h-[3rem]">{product.title}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="shrink-0">{getTypeLabel(product.type)}</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>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{stripHtml(product.description)}
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function WebinarRecording() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [accentColor, setAccentColor] = useState<string>('');
|
||||
const [hasPurchased, setHasPurchased] = useState(false);
|
||||
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||
const playerRef = useRef<VideoPlayerRef>(null);
|
||||
@@ -77,7 +78,9 @@ export default function WebinarRecording() {
|
||||
|
||||
setProduct(productData);
|
||||
|
||||
if (!productData.recording_url) {
|
||||
// Check if any recording exists (YouTube, M3U8, or MP4)
|
||||
const hasRecording = productData.recording_url || productData.m3u8_url || productData.mp4_url;
|
||||
if (!hasRecording) {
|
||||
toast({ title: 'Info', description: 'Rekaman webinar belum tersedia', variant: 'destructive' });
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
@@ -113,7 +116,10 @@ export default function WebinarRecording() {
|
||||
order.order_items?.some((item: any) => item.product_id === productData.id)
|
||||
);
|
||||
|
||||
if (!hasDirectAccess && !hasPaidOrderAccess) {
|
||||
const hasAccess = hasDirectAccess || hasPaidOrderAccess;
|
||||
setHasPurchased(hasAccess);
|
||||
|
||||
if (!hasAccess) {
|
||||
toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke webinar ini', variant: 'destructive' });
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
|
||||
@@ -25,12 +25,10 @@ export default function AdminBootcamp() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
else fetchBootcamps();
|
||||
if (user && isAdmin) {
|
||||
fetchBootcamps();
|
||||
}
|
||||
}, [user, isAdmin, authLoading]);
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const fetchBootcamps = async () => {
|
||||
const { data, error } = await supabase
|
||||
|
||||
@@ -77,17 +77,14 @@ export default function AdminConsulting() {
|
||||
const [editTotalDuration, setEditTotalDuration] = useState(0);
|
||||
const [isRescheduling, setIsRescheduling] = useState(false);
|
||||
const [notifyMember, setNotifyMember] = useState(true);
|
||||
const [cleaningCalendar, setCleaningCalendar] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
else {
|
||||
if (user && isAdmin) {
|
||||
fetchSessions();
|
||||
fetchSettings();
|
||||
}
|
||||
}
|
||||
}, [user, isAdmin, authLoading]);
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
// Fetch sessions with profile data
|
||||
@@ -119,6 +116,36 @@ export default function AdminConsulting() {
|
||||
if (data) setSettings(data);
|
||||
};
|
||||
|
||||
const handleCalendarCleanup = async () => {
|
||||
if (!confirm('Bersihkan Google Calendar events untuk semua sesi yang sudah dibatalkan?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCleaningCalendar(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('trigger-calendar-cleanup');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = data as { processed?: number; message?: string };
|
||||
toast({
|
||||
title: 'Berhasil',
|
||||
description: result.message || `Calendar events dibersihkan untuk ${result.processed || 0} sesi`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Calendar cleanup error:', error);
|
||||
toast({
|
||||
title: 'Gagal',
|
||||
description: error.message || 'Gagal membersihkan calendar events',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCleaningCalendar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openMeetDialog = (session: ConsultingSession, rescheduleMode: boolean = false) => {
|
||||
setSelectedSession(session);
|
||||
setMeetLink(session.meet_link || '');
|
||||
@@ -609,6 +636,25 @@ export default function AdminConsulting() {
|
||||
>
|
||||
Dibatalkan
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCalendarCleanup}
|
||||
disabled={cleaningCalendar}
|
||||
className="border-orange-600 text-orange-600 hover:bg-orange-50 border-2"
|
||||
>
|
||||
{cleaningCalendar ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
Cleaning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
CleanUp
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{(searchQuery || filterStatus !== 'all') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -75,12 +75,10 @@ export default function AdminEvents() {
|
||||
const [blockForm, setBlockForm] = useState(emptyBlock);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
else fetchData();
|
||||
if (user && isAdmin) {
|
||||
fetchData();
|
||||
}
|
||||
}, [user, isAdmin, authLoading]);
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [eventsRes, blocksRes, productsRes] = await Promise.all([
|
||||
|
||||
@@ -11,8 +11,18 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { Eye, Shield, ShieldOff, Search, X } from "lucide-react";
|
||||
import { Eye, Shield, ShieldOff, Search, X, Trash2 } from "lucide-react";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
@@ -39,6 +49,9 @@ export default function AdminMembers() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterRole, setFilterRole] = useState<string>('all');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
@@ -107,6 +120,89 @@ export default function AdminMembers() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteMember = (member: Member) => {
|
||||
if (member.id === user?.id) {
|
||||
toast({ title: "Error", description: "Tidak bisa menghapus akun sendiri", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setMemberToDelete(member);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const deleteMember = async () => {
|
||||
if (!memberToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const userId = memberToDelete.id;
|
||||
|
||||
// Step 1: Delete auth_otps
|
||||
await supabase.from("auth_otps").delete().eq("user_id", userId);
|
||||
|
||||
// Step 2: Delete order_items (first to avoid FK issues)
|
||||
const { data: orders } = await supabase.from("orders").select("id").eq("user_id", userId);
|
||||
if (orders && orders.length > 0) {
|
||||
const orderIds = orders.map(o => o.id);
|
||||
await supabase.from("order_items").delete().in("order_id", orderIds);
|
||||
}
|
||||
|
||||
// Step 3: Delete orders
|
||||
await supabase.from("orders").delete().eq("user_id", userId);
|
||||
|
||||
// Step 4: Delete user_access
|
||||
await supabase.from("user_access").delete().eq("user_id", userId);
|
||||
|
||||
// Step 5: Delete video_progress
|
||||
await supabase.from("video_progress").delete().eq("user_id", userId);
|
||||
|
||||
// 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);
|
||||
|
||||
// Step 8: Delete calendar_events
|
||||
await supabase.from("calendar_events").delete().eq("user_id", userId);
|
||||
|
||||
// Step 9: Delete user_roles
|
||||
await supabase.from("user_roles").delete().eq("user_id", userId);
|
||||
|
||||
// Step 10: Delete profile
|
||||
await supabase.from("profiles").delete().eq("id", userId);
|
||||
|
||||
// Step 11: Delete from auth.users using edge function
|
||||
const { error: deleteError } = await supabase.functions.invoke('delete-user', {
|
||||
body: { user_id: userId }
|
||||
});
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Error deleting from auth.users:', deleteError);
|
||||
throw new Error(`Gagal menghapus user dari auth: ${deleteError.message}`);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Berhasil",
|
||||
description: `Member ${memberToDelete.email || memberToDelete.name} berhasil dihapus beserta semua data terkait`
|
||||
});
|
||||
|
||||
setDeleteDialogOpen(false);
|
||||
setMemberToDelete(null);
|
||||
fetchMembers();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Gagal menghapus member";
|
||||
console.error('Delete member error:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: message,
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
@@ -243,6 +339,15 @@ export default function AdminMembers() {
|
||||
>
|
||||
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => confirmDeleteMember(member)}
|
||||
disabled={member.id === user?.id}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -289,6 +394,16 @@ export default function AdminMembers() {
|
||||
{adminIds.has(member.id) ? <ShieldOff className="w-4 h-4 mr-1" /> : <Shield className="w-4 h-4 mr-1" />}
|
||||
{adminIds.has(member.id) ? "Hapus Admin" : "Jadikan Admin"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => confirmDeleteMember(member)}
|
||||
disabled={member.id === user?.id}
|
||||
className="flex-1 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Hapus
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,6 +449,57 @@ export default function AdminMembers() {
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent className="border-2 border-border">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hapus Member?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
Anda akan menghapus member <strong>{memberToDelete?.email || memberToDelete?.name}</strong>.
|
||||
</p>
|
||||
<p className="text-destructive font-medium">
|
||||
Tindakan ini akan menghapus SEMUA data terkait member ini:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
<li>Order dan item order</li>
|
||||
<li>Akses produk</li>
|
||||
<li>Progress video</li>
|
||||
<li>Jadwal konsultasi</li>
|
||||
<li>Event kalender</li>
|
||||
<li>Role admin (jika ada)</li>
|
||||
<li>Profil user</li>
|
||||
<li>Akun autentikasi</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tindakan ini <strong>TIDAK BISA dibatalkan</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={deleteMember}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<span className="animate-spin mr-2">⏳</span>
|
||||
Menghapus...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Ya, Hapus Member
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@@ -12,9 +12,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { 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 { 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 { formatIDR } from '@/lib/format';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
@@ -45,6 +47,15 @@ interface Product {
|
||||
sale_price: number | null;
|
||||
is_active: boolean;
|
||||
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 = {
|
||||
@@ -64,6 +75,9 @@ const emptyProduct = {
|
||||
sale_price: null as number | null,
|
||||
is_active: true,
|
||||
chapters: [] as VideoChapter[],
|
||||
collaborator_user_id: '',
|
||||
profit_share_percentage: 50,
|
||||
auto_grant_access: true,
|
||||
};
|
||||
|
||||
export default function AdminProducts() {
|
||||
@@ -78,24 +92,36 @@ export default function AdminProducts() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [collaborators, setCollaborators] = useState<CollaboratorProfile[]>([]);
|
||||
const [collaboratorPickerOpen, setCollaboratorPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
else fetchProducts();
|
||||
if (user && isAdmin) {
|
||||
fetchProducts();
|
||||
fetchCollaborators();
|
||||
}
|
||||
}, [user, isAdmin, authLoading]);
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('id, title, slug, type, description, 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 });
|
||||
if (!error && data) setProducts(data);
|
||||
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
|
||||
const filteredProducts = products.filter((product) => {
|
||||
const matchesSearch = product.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -109,7 +135,6 @@ export default function AdminProducts() {
|
||||
|
||||
// Get unique product types from actual products
|
||||
const productTypes = ['all', ...Array.from(new Set(products.map(p => p.type)))];
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setFilterType('all');
|
||||
@@ -137,6 +162,9 @@ export default function AdminProducts() {
|
||||
sale_price: product.sale_price,
|
||||
is_active: product.is_active,
|
||||
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);
|
||||
};
|
||||
@@ -170,16 +198,79 @@ export default function AdminProducts() {
|
||||
sale_price: form.sale_price || null,
|
||||
is_active: form.is_active,
|
||||
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) {
|
||||
const { error } = await supabase.from('products').update(productData).eq('id', editingProduct.id);
|
||||
if (error) toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' });
|
||||
else { toast({ title: 'Berhasil', description: 'Produk diupdate' }); setDialogOpen(false); fetchProducts(); }
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' });
|
||||
} else {
|
||||
const { error } = await supabase.from('products').insert(productData);
|
||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
else { toast({ title: 'Berhasil', description: 'Produk dibuat' }); setDialogOpen(false); fetchProducts(); }
|
||||
const prevCollaboratorId = editingProduct.collaborator_user_id || null;
|
||||
const nextCollaboratorId = productData.collaborator_user_id;
|
||||
|
||||
// Remove old collaborator access when collaborator changed or auto-grant disabled
|
||||
if (prevCollaboratorId && (prevCollaboratorId !== nextCollaboratorId || !productData.auto_grant_access)) {
|
||||
await supabase
|
||||
.from('user_access')
|
||||
.delete()
|
||||
.eq('user_id', prevCollaboratorId)
|
||||
.eq('product_id', editingProduct.id)
|
||||
.eq('access_type', 'collaborator');
|
||||
}
|
||||
|
||||
// Grant collaborator access immediately on assignment (no buyer order needed)
|
||||
if (nextCollaboratorId && productData.auto_grant_access) {
|
||||
const { error: accessError } = await supabase
|
||||
.from('user_access')
|
||||
.upsert({
|
||||
user_id: nextCollaboratorId,
|
||||
product_id: editingProduct.id,
|
||||
access_type: 'collaborator',
|
||||
granted_by: user?.id || null,
|
||||
}, { onConflict: 'user_id,product_id' });
|
||||
|
||||
if (accessError) {
|
||||
toast({ title: 'Warning', description: `Produk tersimpan, tapi grant akses kolaborator gagal: ${accessError.message}`, variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Produk diupdate' });
|
||||
setDialogOpen(false);
|
||||
fetchProducts();
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
};
|
||||
@@ -464,6 +555,95 @@ export default function AdminProducts() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<Label>Deskripsi</Label>
|
||||
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />
|
||||
|
||||
@@ -9,18 +9,13 @@ import { NotifikasiTab } from '@/components/admin/settings/NotifikasiTab';
|
||||
import { KonsultasiTab } from '@/components/admin/settings/KonsultasiTab';
|
||||
import { BrandingTab } from '@/components/admin/settings/BrandingTab';
|
||||
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() {
|
||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) navigate('/auth');
|
||||
else if (!isAdmin) navigate('/dashboard');
|
||||
}
|
||||
}, [user, isAdmin, authLoading, navigate]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
@@ -40,7 +35,7 @@ export default function AdminSettings() {
|
||||
<p className="text-muted-foreground mb-8">Konfigurasi platform</p>
|
||||
|
||||
<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">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Jam Kerja</span>
|
||||
@@ -61,6 +56,10 @@ export default function AdminSettings() {
|
||||
<Puzzle className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Integrasi</span>
|
||||
</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>
|
||||
|
||||
<TabsContent value="workhours">
|
||||
@@ -82,6 +81,10 @@ export default function AdminSettings() {
|
||||
<TabsContent value="integrasi">
|
||||
<IntegrasiTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="collaboration">
|
||||
<CollaborationTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,10 @@ interface UserAccess {
|
||||
type: string;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
m3u8_url: string | null;
|
||||
mp4_url: string | null;
|
||||
video_host: 'youtube' | 'adilo' | 'unknown' | null;
|
||||
event_start: string | null;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
@@ -47,16 +51,15 @@ export default function MemberAccess() {
|
||||
const [selectedType, setSelectedType] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate('/auth');
|
||||
else if (user) fetchAccess();
|
||||
}, [user, authLoading]);
|
||||
if (user) fetchAccess();
|
||||
}, [user]);
|
||||
|
||||
const fetchAccess = async () => {
|
||||
const [accessRes, paidOrdersRes, consultingRes] = await Promise.all([
|
||||
// Get direct user_access
|
||||
supabase
|
||||
.from('user_access')
|
||||
.select(`id, granted_at, expires_at, product:products (id, title, slug, type, meeting_link, recording_url, description)`)
|
||||
.select(`id, granted_at, expires_at, product:products (id, title, slug, type, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, description)`)
|
||||
.eq('user_id', user!.id),
|
||||
// Get products from paid orders (via order_items)
|
||||
supabase
|
||||
@@ -64,7 +67,7 @@ export default function MemberAccess() {
|
||||
.select(
|
||||
`
|
||||
order_items (
|
||||
product:products (id, title, slug, type, meeting_link, recording_url, description)
|
||||
product:products (id, title, slug, type, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, description)
|
||||
)
|
||||
`,
|
||||
)
|
||||
@@ -152,8 +155,11 @@ export default function MemberAccess() {
|
||||
// Check if webinar has ended
|
||||
const webinarEnded = item.product.event_start && new Date(item.product.event_start) <= new Date();
|
||||
|
||||
// Check if any recording exists (YouTube, M3U8, or MP4)
|
||||
const hasRecording = item.product.recording_url || item.product.m3u8_url || item.product.mp4_url;
|
||||
|
||||
// If recording exists, show it
|
||||
if (item.product.recording_url) {
|
||||
if (hasRecording) {
|
||||
return (
|
||||
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatIDR } from "@/lib/format";
|
||||
import { Video, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react";
|
||||
import { Video, ArrowRight, Package, Receipt, ShoppingBag, Wallet } from "lucide-react";
|
||||
import { WhatsAppBanner } from "@/components/WhatsAppBanner";
|
||||
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
|
||||
import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert";
|
||||
@@ -58,6 +58,7 @@ export default function MemberDashboard() {
|
||||
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasWhatsApp, setHasWhatsApp] = useState(true);
|
||||
const [isCollaborator, setIsCollaborator] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate("/auth");
|
||||
@@ -122,7 +123,7 @@ export default function MemberDashboard() {
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [accessRes, ordersRes, paidOrdersRes, profileRes, slotsRes] = await Promise.all([
|
||||
const [accessRes, ordersRes, paidOrdersRes, profileRes, slotsRes, walletRes, collaboratorProductRes] = await Promise.all([
|
||||
supabase
|
||||
.from("user_access")
|
||||
.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("status", "confirmed")
|
||||
.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
|
||||
@@ -170,6 +173,7 @@ export default function MemberDashboard() {
|
||||
if (ordersRes.data) setRecentOrders(ordersRes.data);
|
||||
if (profileRes.data) setHasWhatsApp(!!profileRes.data.whatsapp_number);
|
||||
if (slotsRes.data) setConsultingSlots(slotsRes.data as unknown as ConsultingSlot[]);
|
||||
setIsCollaborator(!!walletRes?.data || !!(collaboratorProductRes?.data && collaboratorProductRes.data.length > 0));
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -282,6 +286,22 @@ export default function MemberDashboard() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
{access.length > 0 && (
|
||||
|
||||
@@ -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;
|
||||
@@ -19,6 +22,11 @@ interface Profile {
|
||||
avatar_url: string | null;
|
||||
whatsapp_number: string | null;
|
||||
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() {
|
||||
@@ -27,17 +35,22 @@ export default function MemberProfile() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
avatar_url: '',
|
||||
whatsapp_number: '',
|
||||
whatsapp_opt_in: false,
|
||||
bio: '',
|
||||
portfolio_url: '',
|
||||
bank_name: '',
|
||||
bank_account_name: '',
|
||||
bank_account_number: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate('/auth');
|
||||
else if (user) fetchProfile();
|
||||
}, [user, authLoading]);
|
||||
if (user) fetchProfile();
|
||||
}, [user]);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
const { data } = await supabase
|
||||
@@ -52,6 +65,11 @@ export default function MemberProfile() {
|
||||
avatar_url: data.avatar_url || '',
|
||||
whatsapp_number: data.whatsapp_number || '',
|
||||
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);
|
||||
@@ -82,6 +100,11 @@ export default function MemberProfile() {
|
||||
avatar_url: form.avatar_url || null,
|
||||
whatsapp_number: normalizedWA || null,
|
||||
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);
|
||||
|
||||
@@ -94,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('/');
|
||||
@@ -139,10 +185,52 @@ export default function MemberProfile() {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
value={form.avatar_url}
|
||||
onChange={(e) => setForm({ ...form, avatar_url: e.target.value })}
|
||||
value={form.bio}
|
||||
onChange={(e) => setForm({ ...form, bio: e.target.value })}
|
||||
className="border-2"
|
||||
placeholder="Tentang Anda"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Portfolio URL</Label>
|
||||
<Input
|
||||
value={form.portfolio_url}
|
||||
onChange={(e) => setForm({ ...form, portfolio_url: e.target.value })}
|
||||
className="border-2"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
@@ -185,6 +273,41 @@ export default function MemberProfile() {
|
||||
</CardContent>
|
||||
</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">
|
||||
{saving ? 'Menyimpan...' : 'Simpan Profil'}
|
||||
</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" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
61
supabase/functions/delete-user/index.ts
Normal file
61
supabase/functions/delete-user/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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 DeleteUserRequest {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const body: DeleteUserRequest = await req.json();
|
||||
const { user_id } = body;
|
||||
|
||||
if (!user_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "user_id is required" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Deleting user from auth.users: ${user_id}`);
|
||||
|
||||
// Delete user from auth.users using admin API
|
||||
const { error: deleteError } = await supabase.auth.admin.deleteUser(user_id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Error deleting user from auth.users:', deleteError);
|
||||
throw new Error(`Failed to delete user from auth: ${deleteError.message}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully deleted user: ${user_id}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "User deleted successfully" }),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting user:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: 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),
|
||||
order_items (
|
||||
id,
|
||||
product_id,
|
||||
product:products (title, type)
|
||||
unit_price,
|
||||
product:products (title, type, collaborator_user_id, profit_share_percentage, auto_grant_access)
|
||||
),
|
||||
consulting_sessions (
|
||||
id,
|
||||
@@ -80,8 +82,16 @@ serve(async (req: Request): Promise<Response> => {
|
||||
const userEmail = order.profiles?.email || "";
|
||||
const userName = order.profiles?.name || userEmail.split('@')[0] || "Pelanggan";
|
||||
const orderItems = order.order_items as Array<{
|
||||
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
|
||||
@@ -218,6 +228,84 @@ serve(async (req: Request): Promise<Response> => {
|
||||
});
|
||||
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);
|
||||
@@ -257,12 +345,13 @@ serve(async (req: Request): Promise<Response> => {
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("[HANDLE-PAID] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Internal server error";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Internal server error"
|
||||
error: message
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
@@ -271,9 +360,9 @@ serve(async (req: Request): Promise<Response> => {
|
||||
|
||||
// Helper function to send notification
|
||||
async function sendNotification(
|
||||
supabase: any,
|
||||
supabase: ReturnType<typeof createClient>,
|
||||
templateKey: string,
|
||||
data: Record<string, any>
|
||||
data: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
console.log("[HANDLE-PAID] Sending notification:", templateKey);
|
||||
|
||||
@@ -309,18 +398,30 @@ async function sendNotification(
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email via Mailketing
|
||||
await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email-v2`, {
|
||||
// Send email via send-notification (which will process shortcodes and call send-email-v2)
|
||||
try {
|
||||
const notificationResponse = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-notification`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: data.email,
|
||||
subject: template.email_subject,
|
||||
html: template.email_body_html,
|
||||
shortcodeData: data,
|
||||
template_key: templateKey,
|
||||
recipient_email: String(data.email || ""),
|
||||
recipient_name: String((data.user_name as string) || (data.nama as string) || ""),
|
||||
variables: data,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!notificationResponse.ok) {
|
||||
const errorText = await notificationResponse.text();
|
||||
console.error("[HANDLE-PAID] Notification send failed:", notificationResponse.status, errorText);
|
||||
} else {
|
||||
const result = await notificationResponse.json();
|
||||
console.log("[HANDLE-PAID] Notification sent successfully for template:", templateKey, result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HANDLE-PAID] Exception sending notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
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" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
@@ -12,11 +11,6 @@ interface SendOTPRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Generate 6-digit OTP code
|
||||
function generateOTP(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
@@ -33,191 +27,88 @@ serve(async (req: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Invalid email format" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Supabase client with service role
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Generate OTP code
|
||||
const otpCode = generateOTP();
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now
|
||||
// Fetch platform settings for brand name and URL
|
||||
const { data: platformSettings } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('brand_name, platform_url')
|
||||
.single();
|
||||
|
||||
console.log(`Generating OTP for user ${user_id}, email ${email}`);
|
||||
const platformName = platformSettings?.brand_name || 'ACCESS HUB';
|
||||
const platformUrl = platformSettings?.platform_url || 'https://access-hub.com';
|
||||
|
||||
console.log(`Generating OTP for user ${user_id}`);
|
||||
|
||||
// Generate 6-digit OTP code
|
||||
const otpCode = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
|
||||
// Calculate expiration time (15 minutes from now)
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
||||
|
||||
// Store OTP in database
|
||||
const { error: otpError } = await supabase
|
||||
const { error: insertError } = await supabase
|
||||
.from('auth_otps')
|
||||
.insert({
|
||||
user_id,
|
||||
email,
|
||||
otp_code: otpCode,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
if (otpError) {
|
||||
console.error('Error storing OTP:', otpError);
|
||||
throw new Error(`Failed to store OTP: ${otpError.message}`);
|
||||
}
|
||||
|
||||
// Get notification settings
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (settingsError || !settings) {
|
||||
console.error('Error fetching notification settings:', settingsError);
|
||||
throw new Error('Notification settings not configured');
|
||||
}
|
||||
|
||||
// Get platform settings for brand_name
|
||||
const { data: platformSettings, error: platformError } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('brand_name')
|
||||
.single();
|
||||
|
||||
if (platformError) {
|
||||
console.error('Error fetching platform settings:', platformError);
|
||||
// Continue with fallback if platform settings not found
|
||||
}
|
||||
|
||||
const brandName = platformSettings?.brand_name || settings.platform_name || 'ACCESS HUB';
|
||||
|
||||
// Get email template
|
||||
console.log('Fetching email template with key: auth_email_verification');
|
||||
|
||||
const { data: template, error: templateError } = await supabase
|
||||
.from('notification_templates')
|
||||
.select('*')
|
||||
.eq('key', 'auth_email_verification')
|
||||
.single();
|
||||
|
||||
console.log('Template query result:', { template, templateError });
|
||||
|
||||
if (templateError || !template) {
|
||||
console.error('Error fetching email template:', templateError);
|
||||
throw new Error('Email template not found. Please create template with key: auth_email_verification');
|
||||
}
|
||||
|
||||
// Get user data from auth.users
|
||||
const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(user_id);
|
||||
|
||||
if (userError || !user) {
|
||||
console.error('Error fetching user:', userError);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Prepare template variables
|
||||
const templateVars = {
|
||||
platform_name: brandName,
|
||||
nama: user.user_metadata?.name || user.email || 'Pengguna',
|
||||
user_id: user_id,
|
||||
email: email,
|
||||
otp_code: otpCode,
|
||||
expiry_minutes: '15',
|
||||
confirmation_link: '', // Not used for OTP
|
||||
year: new Date().getFullYear().toString(),
|
||||
};
|
||||
|
||||
// Process shortcodes in subject
|
||||
let subject = template.email_subject;
|
||||
Object.entries(templateVars).forEach(([key, value]) => {
|
||||
subject = subject.replace(new RegExp(`{${key}}`, 'g'), value);
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
// Process shortcodes in HTML body content
|
||||
let htmlContent = template.email_body_html;
|
||||
Object.entries(templateVars).forEach(([key, value]) => {
|
||||
htmlContent = htmlContent.replace(new RegExp(`{${key}}`, 'g'), value);
|
||||
});
|
||||
|
||||
// Wrap in master template
|
||||
const htmlBody = EmailTemplateRenderer.render({
|
||||
subject: subject,
|
||||
content: htmlContent,
|
||||
brandName: brandName,
|
||||
});
|
||||
|
||||
// Send email via send-email-v2
|
||||
console.log(`Sending OTP email to ${email}`);
|
||||
console.log('Settings:', {
|
||||
hasMailketingToken: !!settings.mailketing_api_token,
|
||||
hasApiToken: !!settings.api_token,
|
||||
hasFromName: !!settings.from_name,
|
||||
hasFromEmail: !!settings.from_email,
|
||||
platformName: settings.platform_name,
|
||||
});
|
||||
|
||||
// Use api_token (not mailketing_api_token)
|
||||
const apiToken = settings.api_token || settings.mailketing_api_token;
|
||||
|
||||
if (!apiToken) {
|
||||
throw new Error('API token not found in notification_settings');
|
||||
if (insertError) {
|
||||
console.error('Error storing OTP:', insertError);
|
||||
throw new Error(`Failed to store OTP: ${insertError.message}`);
|
||||
}
|
||||
|
||||
// Log email details (truncate HTML body for readability)
|
||||
console.log('Email payload:', {
|
||||
recipient: email,
|
||||
from_name: settings.from_name || brandName,
|
||||
from_email: settings.from_email || 'noreply@example.com',
|
||||
subject: subject,
|
||||
content_length: htmlBody.length,
|
||||
content_preview: htmlBody.substring(0, 200),
|
||||
});
|
||||
console.log(`OTP generated and stored: ${otpCode}, expires at: ${expiresAt}`);
|
||||
|
||||
const emailResponse = await fetch(`${supabaseUrl}/functions/v1/send-email-v2`, {
|
||||
// Send OTP email using send-notification
|
||||
const notificationUrl = `${supabaseUrl}/functions/v1/send-notification`;
|
||||
const notificationResponse = await fetch(notificationUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${supabaseServiceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient: email,
|
||||
api_token: apiToken,
|
||||
from_name: settings.from_name || brandName,
|
||||
from_email: settings.from_email || 'noreply@example.com',
|
||||
subject: subject,
|
||||
content: htmlBody,
|
||||
template_key: 'auth_email_verification',
|
||||
recipient_email: email,
|
||||
recipient_name: email.split('@')[0],
|
||||
variables: {
|
||||
nama: email.split('@')[0],
|
||||
otp_code: otpCode,
|
||||
email: email,
|
||||
user_id: user_id,
|
||||
expiry_minutes: '15',
|
||||
platform_name: platformName,
|
||||
platform_url: platformUrl
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!emailResponse.ok) {
|
||||
const errorText = await emailResponse.text();
|
||||
console.error('Email send error:', emailResponse.status, errorText);
|
||||
throw new Error(`Failed to send email: ${emailResponse.status} ${errorText}`);
|
||||
if (!notificationResponse.ok) {
|
||||
const errorText = await notificationResponse.text();
|
||||
console.error('Error sending notification email:', notificationResponse.status, errorText);
|
||||
throw new Error(`Failed to send OTP email: ${notificationResponse.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const emailResult = await emailResponse.json();
|
||||
console.log('Email sent successfully:', emailResult);
|
||||
|
||||
// Note: notification_logs table doesn't exist, skipping logging
|
||||
const notificationResult = await notificationResponse.json();
|
||||
console.log('Notification sent successfully:', notificationResult);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'OTP sent successfully'
|
||||
message: "OTP sent successfully"
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error sending OTP:", error);
|
||||
|
||||
// Note: notification_logs table doesn't exist, skipping error logging
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
|
||||
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" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
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 supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get current date/time in Jakarta timezone
|
||||
const now = new Date();
|
||||
const jakartaOffset = 7 * 60; // UTC+7
|
||||
const jakartaTime = new Date(now.getTime() + jakartaOffset * 60 * 1000);
|
||||
const today = jakartaTime.toISOString().split('T')[0];
|
||||
|
||||
// Find consultations happening in the next 24 hours that haven't been reminded
|
||||
const tomorrow = new Date(jakartaTime);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
console.log("Checking consultations for dates:", today, "to", tomorrowStr);
|
||||
|
||||
// Get confirmed slots for today and tomorrow
|
||||
const { data: upcomingSlots, error: slotsError } = await supabase
|
||||
.from("consulting_slots")
|
||||
.select(`
|
||||
*,
|
||||
profiles:user_id (full_name, email)
|
||||
`)
|
||||
.eq("status", "confirmed")
|
||||
.gte("date", today)
|
||||
.lte("date", tomorrowStr)
|
||||
.order("date")
|
||||
.order("start_time");
|
||||
|
||||
if (slotsError) {
|
||||
console.error("Error fetching slots:", slotsError);
|
||||
throw slotsError;
|
||||
}
|
||||
|
||||
console.log("Found upcoming slots:", upcomingSlots?.length || 0);
|
||||
|
||||
if (!upcomingSlots || upcomingSlots.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "No upcoming consultations to remind" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get notification template for consultation reminder
|
||||
const { data: template } = await supabase
|
||||
.from("notification_templates")
|
||||
.select("*")
|
||||
.eq("key", "consulting_scheduled")
|
||||
.single();
|
||||
|
||||
// Get SMTP settings
|
||||
const { data: smtpSettings } = await supabase
|
||||
.from("notification_settings")
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
// Get platform settings
|
||||
const { data: platformSettings } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("brand_name, brand_email_from_name, integration_whatsapp_number")
|
||||
.single();
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const slot of upcomingSlots) {
|
||||
const profile = slot.profiles as any;
|
||||
|
||||
// Build payload for notification
|
||||
const payload = {
|
||||
nama: profile?.full_name || "Pelanggan",
|
||||
email: profile?.email || "",
|
||||
tanggal_konsultasi: new Date(slot.date).toLocaleDateString("id-ID", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
jam_konsultasi: `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)} WIB`,
|
||||
link_meet: slot.meet_link || "Akan diinformasikan",
|
||||
topik: slot.topic_category,
|
||||
catatan: slot.notes || "-",
|
||||
brand_name: platformSettings?.brand_name || "LearnHub",
|
||||
whatsapp: platformSettings?.integration_whatsapp_number || "",
|
||||
};
|
||||
|
||||
// Log the reminder payload
|
||||
console.log("Reminder payload for slot:", slot.id, payload);
|
||||
|
||||
// Update last_payload_example in template
|
||||
if (template) {
|
||||
await supabase
|
||||
.from("notification_templates")
|
||||
.update({ last_payload_example: payload })
|
||||
.eq("id", template.id);
|
||||
}
|
||||
|
||||
// Send webhook if configured
|
||||
if (template?.webhook_url) {
|
||||
try {
|
||||
await fetch(template.webhook_url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event: "consulting_reminder",
|
||||
slot_id: slot.id,
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
console.log("Webhook sent for slot:", slot.id);
|
||||
} catch (webhookError) {
|
||||
console.error("Webhook error:", webhookError);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email if template is active and Mailketing is configured
|
||||
if (template?.is_active && smtpSettings?.api_token && profile?.email) {
|
||||
try {
|
||||
// Replace shortcodes in email body using master template system
|
||||
let emailBody = template.email_body_html || "";
|
||||
let emailSubject = template.email_subject || "Reminder Konsultasi";
|
||||
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, "g");
|
||||
emailBody = emailBody.replace(regex, String(value));
|
||||
emailSubject = emailSubject.replace(regex, String(value));
|
||||
});
|
||||
|
||||
// Send via send-email-v2 (Mailketing API)
|
||||
const { error: emailError } = await supabase.functions.invoke("send-email-v2", {
|
||||
body: {
|
||||
recipient: profile.email,
|
||||
api_token: smtpSettings.api_token,
|
||||
from_name: smtpSettings.from_name || platformSettings?.brand_name || "Access Hub",
|
||||
from_email: smtpSettings.from_email || "noreply@with.dwindi.com",
|
||||
subject: emailSubject,
|
||||
content: emailBody,
|
||||
},
|
||||
});
|
||||
|
||||
if (emailError) {
|
||||
console.error("Failed to send reminder email:", emailError);
|
||||
} else {
|
||||
console.log("Reminder email sent to:", profile.email);
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error("Error sending reminder email:", emailError);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
slot_id: slot.id,
|
||||
client: profile?.full_name,
|
||||
date: slot.date,
|
||||
time: slot.start_time,
|
||||
reminded: true,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: `Processed ${results.length} consultation reminders`,
|
||||
results
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error sending reminders:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: error.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
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": "*",
|
||||
@@ -7,22 +8,24 @@ const corsHeaders = {
|
||||
|
||||
interface EmailRequest {
|
||||
recipient: string;
|
||||
api_token: string;
|
||||
from_name: string;
|
||||
from_email: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Send via Mailketing API
|
||||
async function sendViaMailketing(request: EmailRequest): Promise<{ success: boolean; message: string }> {
|
||||
const { recipient, api_token, from_name, from_email, subject, content } = request;
|
||||
async function sendViaMailketing(
|
||||
request: EmailRequest,
|
||||
apiToken: string,
|
||||
fromName: string,
|
||||
fromEmail: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const { recipient, subject, content } = request;
|
||||
|
||||
// Build form-encoded body (http_build_query format)
|
||||
const params = new URLSearchParams();
|
||||
params.append('api_token', api_token);
|
||||
params.append('from_name', from_name);
|
||||
params.append('from_email', from_email);
|
||||
params.append('api_token', apiToken);
|
||||
params.append('from_name', fromName);
|
||||
params.append('from_email', fromEmail);
|
||||
params.append('recipient', recipient);
|
||||
params.append('subject', subject);
|
||||
params.append('content', content);
|
||||
@@ -58,19 +61,46 @@ serve(async (req: Request): Promise<Response> => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Supabase client
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Fetch email settings from platform_settings
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (settingsError || !settings) {
|
||||
console.error('Error fetching platform settings:', settingsError);
|
||||
throw new Error('Failed to fetch email configuration from platform_settings');
|
||||
}
|
||||
|
||||
const apiToken = settings.integration_email_api_token;
|
||||
const fromName = settings.integration_email_from_name || settings.brand_name;
|
||||
const fromEmail = settings.integration_email_from_email;
|
||||
|
||||
if (!apiToken || !fromEmail) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Email not configured. Please set API token and from email in platform settings." }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const body: EmailRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!body.recipient || !body.api_token || !body.from_name || !body.from_email || !body.subject || !body.content) {
|
||||
if (!body.recipient || !body.subject || !body.content) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Missing required fields: recipient, api_token, from_name, from_email, subject, content" }),
|
||||
JSON.stringify({ success: false, message: "Missing required fields: recipient, subject, content" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(body.recipient) || !emailRegex.test(body.from_email)) {
|
||||
if (!emailRegex.test(body.recipient) || !emailRegex.test(fromEmail)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Invalid email format" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
@@ -78,10 +108,10 @@ serve(async (req: Request): Promise<Response> => {
|
||||
}
|
||||
|
||||
console.log(`Attempting to send email to: ${body.recipient}`);
|
||||
console.log(`From: ${body.from_name} <${body.from_email}>`);
|
||||
console.log(`From: ${fromName} <${fromEmail}>`);
|
||||
console.log(`Subject: ${body.subject}`);
|
||||
|
||||
const result = await sendViaMailketing(body);
|
||||
const result = await sendViaMailketing(body, apiToken, fromName, fromEmail);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(result),
|
||||
|
||||
@@ -32,6 +32,36 @@ interface EmailPayload {
|
||||
from_email: string;
|
||||
}
|
||||
|
||||
// Send via Mailketing API
|
||||
async function sendViaMailketing(payload: EmailPayload, apiToken: string): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('api_token', apiToken);
|
||||
params.append('from_name', payload.from_name);
|
||||
params.append('from_email', payload.from_email);
|
||||
params.append('recipient', payload.to);
|
||||
params.append('subject', payload.subject);
|
||||
params.append('content', payload.html);
|
||||
|
||||
console.log(`Sending email via Mailketing to ${payload.to}`);
|
||||
|
||||
const response = await fetch('https://api.mailketing.co.id/api/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Mailketing API error:', response.status, errorText);
|
||||
throw new Error(`Mailketing API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Mailketing API response:', result);
|
||||
}
|
||||
|
||||
// Send via SMTP
|
||||
async function sendViaSMTP(payload: EmailPayload, config: SMTPConfig): Promise<void> {
|
||||
const boundary = "----=_Part_" + Math.random().toString(36).substr(2, 9);
|
||||
@@ -192,7 +222,9 @@ async function sendViaMailgun(payload: EmailPayload, apiKey: string, domain: str
|
||||
function replaceVariables(template: string, variables: Record<string, string>): string {
|
||||
let result = template;
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
// Support both {key} and {{key}} formats
|
||||
result = result.replace(new RegExp(`{{${key}}}`, 'g'), value);
|
||||
result = result.replace(new RegExp(`{${key}}`, 'g'), value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -214,7 +246,7 @@ serve(async (req: Request): Promise<Response> => {
|
||||
const { data: template, error: templateError } = await supabase
|
||||
.from("notification_templates")
|
||||
.select("*")
|
||||
.eq("template_key", template_key)
|
||||
.eq("key", template_key)
|
||||
.eq("is_active", true)
|
||||
.single();
|
||||
|
||||
@@ -226,88 +258,60 @@ serve(async (req: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
|
||||
// Get platform settings
|
||||
const { data: settings } = await supabase
|
||||
// Get platform settings (includes email configuration)
|
||||
const { data: platformSettings, error: platformError } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (!settings) {
|
||||
if (platformError || !platformSettings) {
|
||||
console.error('Error fetching platform settings:', platformError);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Platform settings not configured" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const brandName = platformSettings.brand_name || "ACCESS HUB";
|
||||
|
||||
// Build email payload
|
||||
const allVariables = {
|
||||
recipient_name: recipient_name || "Pelanggan",
|
||||
platform_name: settings.brand_name || "Platform",
|
||||
platform_name: brandName,
|
||||
...variables,
|
||||
};
|
||||
|
||||
const subject = replaceVariables(template.subject, allVariables);
|
||||
const htmlContent = replaceVariables(template.body_html || template.body_text || "", allVariables);
|
||||
const subject = replaceVariables(template.email_subject || template.subject || "", allVariables);
|
||||
const htmlContent = replaceVariables(template.email_body_html || template.body_html || template.body_text || "", allVariables);
|
||||
|
||||
// Wrap with master template for consistent branding
|
||||
const htmlBody = EmailTemplateRenderer.render({
|
||||
subject: subject,
|
||||
content: htmlContent,
|
||||
brandName: settings.brand_name || "ACCESS HUB",
|
||||
brandName: brandName,
|
||||
});
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
to: recipient_email,
|
||||
subject,
|
||||
html: htmlBody,
|
||||
from_name: settings.brand_email_from_name || settings.brand_name || "Notifikasi",
|
||||
from_email: settings.smtp_from_email || "noreply@example.com",
|
||||
from_name: platformSettings.integration_email_from_name || brandName || "Notifikasi",
|
||||
from_email: platformSettings.integration_email_from_email || "noreply@example.com",
|
||||
};
|
||||
|
||||
// Determine provider and send
|
||||
const provider = settings.integration_email_provider || "smtp";
|
||||
const provider = platformSettings.integration_email_provider || "mailketing";
|
||||
console.log(`Sending email via ${provider} to ${recipient_email}`);
|
||||
|
||||
switch (provider) {
|
||||
case "smtp":
|
||||
await sendViaSMTP(emailPayload, {
|
||||
host: settings.smtp_host,
|
||||
port: settings.smtp_port || 587,
|
||||
username: settings.smtp_username,
|
||||
password: settings.smtp_password,
|
||||
from_name: emailPayload.from_name,
|
||||
from_email: emailPayload.from_email,
|
||||
use_tls: settings.smtp_use_tls ?? true,
|
||||
});
|
||||
break;
|
||||
|
||||
case "resend":
|
||||
const resendKey = Deno.env.get("RESEND_API_KEY");
|
||||
if (!resendKey) throw new Error("RESEND_API_KEY not configured");
|
||||
await sendViaResend(emailPayload, resendKey);
|
||||
break;
|
||||
|
||||
case "elasticemail":
|
||||
const elasticKey = Deno.env.get("ELASTICEMAIL_API_KEY");
|
||||
if (!elasticKey) throw new Error("ELASTICEMAIL_API_KEY not configured");
|
||||
await sendViaElasticEmail(emailPayload, elasticKey);
|
||||
break;
|
||||
|
||||
case "sendgrid":
|
||||
const sendgridKey = Deno.env.get("SENDGRID_API_KEY");
|
||||
if (!sendgridKey) throw new Error("SENDGRID_API_KEY not configured");
|
||||
await sendViaSendGrid(emailPayload, sendgridKey);
|
||||
break;
|
||||
|
||||
case "mailgun":
|
||||
const mailgunKey = Deno.env.get("MAILGUN_API_KEY");
|
||||
const mailgunDomain = Deno.env.get("MAILGUN_DOMAIN");
|
||||
if (!mailgunKey || !mailgunDomain) throw new Error("MAILGUN credentials not configured");
|
||||
await sendViaMailgun(emailPayload, mailgunKey, mailgunDomain);
|
||||
case "mailketing":
|
||||
const mailketingToken = platformSettings.integration_email_api_token;
|
||||
if (!mailketingToken) throw new Error("Mailketing API token not configured");
|
||||
await sendViaMailketing(emailPayload, mailketingToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown email provider: ${provider}`);
|
||||
throw new Error(`Unknown email provider: ${provider}. Only 'mailketing' is supported.`);
|
||||
}
|
||||
|
||||
// Log notification
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface TestEmailRequest {
|
||||
to: string;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_from_name: string;
|
||||
smtp_from_email: string;
|
||||
smtp_use_tls: boolean;
|
||||
}
|
||||
|
||||
async function sendEmail(config: TestEmailRequest): Promise<{ success: boolean; message: string }> {
|
||||
const { to, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from_name, smtp_from_email, smtp_use_tls } = config;
|
||||
|
||||
// Build email content
|
||||
const boundary = "----=_Part_" + Math.random().toString(36).substr(2, 9);
|
||||
const emailContent = [
|
||||
`From: "${smtp_from_name}" <${smtp_from_email}>`,
|
||||
`To: ${to}`,
|
||||
`Subject: =?UTF-8?B?${btoa("Email Uji Coba - Konfigurasi SMTP Berhasil")}?=`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=UTF-8`,
|
||||
``,
|
||||
`Ini adalah email uji coba dari sistem notifikasi Anda.`,
|
||||
`Jika Anda menerima email ini, konfigurasi SMTP Anda sudah benar.`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/html; charset=UTF-8`,
|
||||
``,
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #0066cc;">Email Uji Coba Berhasil! ✓</h2>
|
||||
<p>Ini adalah email uji coba dari sistem notifikasi Anda.</p>
|
||||
<p>Jika Anda menerima email ini, konfigurasi SMTP Anda sudah benar.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
Dikirim dari: ${smtp_from_email}<br>
|
||||
Server: ${smtp_host}:${smtp_port}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
`--${boundary}--`,
|
||||
].join("\r\n");
|
||||
|
||||
// Connect to SMTP server
|
||||
const conn = smtp_use_tls
|
||||
? await Deno.connectTls({ hostname: smtp_host, port: smtp_port })
|
||||
: await Deno.connect({ hostname: smtp_host, port: smtp_port });
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
async function readResponse(): Promise<string> {
|
||||
const buffer = new Uint8Array(1024);
|
||||
const n = await conn.read(buffer);
|
||||
if (n === null) return "";
|
||||
return decoder.decode(buffer.subarray(0, n));
|
||||
}
|
||||
|
||||
async function sendCommand(cmd: string): Promise<string> {
|
||||
await conn.write(encoder.encode(cmd + "\r\n"));
|
||||
return await readResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
// Read greeting
|
||||
await readResponse();
|
||||
|
||||
// EHLO
|
||||
let response = await sendCommand(`EHLO localhost`);
|
||||
console.log("EHLO response:", response);
|
||||
|
||||
// For non-TLS connection on port 587, we may need STARTTLS
|
||||
if (!smtp_use_tls && response.includes("STARTTLS")) {
|
||||
await sendCommand("STARTTLS");
|
||||
// Upgrade to TLS - not supported in basic Deno.connect
|
||||
// For now, recommend using TLS directly
|
||||
}
|
||||
|
||||
// AUTH LOGIN
|
||||
response = await sendCommand("AUTH LOGIN");
|
||||
console.log("AUTH response:", response);
|
||||
|
||||
// Username (base64)
|
||||
response = await sendCommand(btoa(smtp_username));
|
||||
console.log("Username response:", response);
|
||||
|
||||
// Password (base64)
|
||||
response = await sendCommand(btoa(smtp_password));
|
||||
console.log("Password response:", response);
|
||||
|
||||
if (!response.includes("235") && !response.includes("Authentication successful")) {
|
||||
throw new Error("Authentication failed: " + response);
|
||||
}
|
||||
|
||||
// MAIL FROM
|
||||
response = await sendCommand(`MAIL FROM:<${smtp_from_email}>`);
|
||||
if (!response.includes("250")) {
|
||||
throw new Error("MAIL FROM failed: " + response);
|
||||
}
|
||||
|
||||
// RCPT TO
|
||||
response = await sendCommand(`RCPT TO:<${to}>`);
|
||||
if (!response.includes("250")) {
|
||||
throw new Error("RCPT TO failed: " + response);
|
||||
}
|
||||
|
||||
// DATA
|
||||
response = await sendCommand("DATA");
|
||||
if (!response.includes("354")) {
|
||||
throw new Error("DATA failed: " + response);
|
||||
}
|
||||
|
||||
// Send email content
|
||||
await conn.write(encoder.encode(emailContent + "\r\n.\r\n"));
|
||||
response = await readResponse();
|
||||
if (!response.includes("250")) {
|
||||
throw new Error("Email send failed: " + response);
|
||||
}
|
||||
|
||||
// QUIT
|
||||
await sendCommand("QUIT");
|
||||
conn.close();
|
||||
|
||||
return { success: true, message: "Email uji coba berhasil dikirim ke " + to };
|
||||
} catch (error) {
|
||||
conn.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
serve(async (req: Request): Promise<Response> => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const body: TestEmailRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!body.to || !body.smtp_host || !body.smtp_username || !body.smtp_password) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Missing required fields" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Attempting to send test email to:", body.to);
|
||||
console.log("SMTP config:", { host: body.smtp_host, port: body.smtp_port, user: body.smtp_username });
|
||||
|
||||
const result = await sendEmail(body);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(result),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Error sending test email:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: error.message || "Failed to send email" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
86
supabase/functions/trigger-calendar-cleanup/index.ts
Normal file
86
supabase/functions/trigger-calendar-cleanup/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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 supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
console.log("[CALENDAR-CLEANUP] Starting calendar cleanup for cancelled sessions");
|
||||
|
||||
// Find cancelled consulting sessions with calendar events
|
||||
const { data: cancelledSessions, error } = await supabase
|
||||
.from("consulting_sessions")
|
||||
.select("id, calendar_event_id")
|
||||
.eq("status", "cancelled")
|
||||
.not("calendar_event_id", "is", null);
|
||||
|
||||
if (error) {
|
||||
console.error("[CALENDAR-CLEANUP] Query error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!cancelledSessions || cancelledSessions.length === 0) {
|
||||
console.log("[CALENDAR-CLEANUP] No cancelled sessions with calendar events found");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "No calendar events to clean up",
|
||||
processed: 0
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[CALENDAR-CLEANUP] Found ${cancelledSessions.length} cancelled sessions with calendar events`);
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
// Delete calendar events for cancelled sessions
|
||||
for (const session of cancelledSessions) {
|
||||
if (session.calendar_event_id) {
|
||||
try {
|
||||
await supabase.functions.invoke('delete-calendar-event', {
|
||||
body: { session_id: session.id }
|
||||
});
|
||||
console.log(`[CALENDAR-CLEANUP] Deleted calendar event for session: ${session.id}`);
|
||||
processedCount++;
|
||||
} catch (err) {
|
||||
console.log(`[CALENDAR-CLEANUP] Failed to delete calendar event: ${err}`);
|
||||
// Continue with other events even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CALENDAR-CLEANUP] Successfully cleaned up ${processedCount} calendar events`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: `Successfully cleaned up ${processedCount} calendar events`,
|
||||
processed: processedCount
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[CALENDAR-CLEANUP] Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Internal server error"
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ DECLARE
|
||||
expired_order RECORD;
|
||||
expired_session RECORD;
|
||||
processed_count INTEGER := 0;
|
||||
calendar_cleanup_count INTEGER := 0;
|
||||
BEGIN
|
||||
-- Log start
|
||||
RAISE NOTICE '[CANCEL-EXPIRED] Starting check for expired consulting orders';
|
||||
@@ -57,6 +58,16 @@ BEGIN
|
||||
DELETE FROM consulting_time_slots
|
||||
WHERE session_id = expired_session.id;
|
||||
|
||||
-- Clear calendar_event_id to mark for cleanup
|
||||
-- Note: The actual Google Calendar event deletion is handled separately
|
||||
-- via the trigger-calendar-cleanup edge function (if HTTP access is available)
|
||||
IF expired_session.calendar_event_id IS NOT NULL THEN
|
||||
UPDATE consulting_sessions
|
||||
SET calendar_event_id = NULL
|
||||
WHERE id = expired_session.id;
|
||||
calendar_cleanup_count := calendar_cleanup_count + 1;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[CANCEL-EXPIRED] Cancelled session: %', expired_session.id;
|
||||
END LOOP;
|
||||
|
||||
@@ -68,7 +79,8 @@ BEGIN
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'processed', processed_count,
|
||||
'message', format('Successfully cancelled %s expired consulting orders', processed_count)
|
||||
'calendar_references_cleared', calendar_cleanup_count,
|
||||
'message', format('Successfully cancelled %s expired consulting orders (cleared %s calendar references)', processed_count, calendar_cleanup_count)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -86,13 +98,18 @@ $$;
|
||||
-- Timeout: 30 seconds
|
||||
-- Container: supabase-db (or supabase-rest if it has psql client)
|
||||
--
|
||||
-- Task 2: Calendar Cleanup (every 15 minutes)
|
||||
-- NOTE: Calendar cleanup is now included in the SQL function above.
|
||||
-- The function clears calendar_event_id references to prevent stale data.
|
||||
-- Actual Google Calendar event deletion can be triggered manually via:
|
||||
-- curl -X POST http://your-domain/functions/v1/trigger-calendar-cleanup
|
||||
--
|
||||
-- Task 2 (DEPRECATED): Calendar cleanup edge function
|
||||
-- -------------------------------------------
|
||||
-- Name: cancel-expired-consulting-orders-calendar
|
||||
-- Command: curl -X POST http://supabase-edge-functions:8000/functions/v1/cancel-expired-consulting-orders
|
||||
-- Frequency: */15 * * * *
|
||||
-- Timeout: 30 seconds
|
||||
-- Container: supabase-edge-functions
|
||||
-- Due to Docker networking limitations between containers, we cannot
|
||||
-- automatically trigger the edge function from the scheduled task.
|
||||
-- The SQL function now handles cleanup of database references.
|
||||
-- To manually clean up Google Calendar events, trigger the edge function:
|
||||
-- POST http://your-supabase-project.supabase.co/functions/v1/trigger-calendar-cleanup
|
||||
|
||||
-- ============================================
|
||||
-- Manual Testing
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- ============================================================================
|
||||
-- Update Auth OTP Email Template - Better Copywriting with Dedicated Page Link
|
||||
-- ============================================================================
|
||||
|
||||
-- Update auth_email_verification template with improved copywriting
|
||||
UPDATE notification_templates
|
||||
SET
|
||||
email_subject = 'Konfirmasi Email Anda - {platform_name}',
|
||||
email_body_html = '---
|
||||
<h1>🔐 Konfirmasi Alamat Email</h1>
|
||||
|
||||
<p>Selamat datang di <strong>{platform_name}</strong>!</p>
|
||||
|
||||
<p>Terima kasih telah mendaftar. Untuk mengaktifkan akun Anda, masukkan kode verifikasi 6 digit berikut:</p>
|
||||
|
||||
<div class="otp-box">{otp_code}</div>
|
||||
|
||||
<p><strong>⏰ Berlaku selama {expiry_minutes} menit</strong></p>
|
||||
|
||||
<h2>🎯 Cara Verifikasi:</h2>
|
||||
<ol>
|
||||
<li><strong>Kembali ke halaman pendaftaran</strong> - Form OTP sudah otomatis muncul</li>
|
||||
<li><strong>Masukkan kode 6 digit</strong> di atas pada kolom verifikasi</li>
|
||||
<li><strong>Klik "Verifikasi Email"</strong> dan akun Anda siap digunakan!</li>
|
||||
</ol>
|
||||
|
||||
<h2>🔄 Halaman Khusus Verifikasi</h2>
|
||||
<p>Jika Anda kehilangan halaman pendaftaran atau tertutup tidak sengaja, jangan khawatir! Anda tetap bisa memverifikasi akun melalui:</p>
|
||||
|
||||
<p class="text-center" style="margin: 20px 0;">
|
||||
<a href="{platform_url}/confirm-otp?user_id={user_id}&email={email}" class="button" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
📧 Buka Halaman Verifikasi Khusus
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
<em>Link ini akan membawa Anda ke halaman khusus untuk memasukkan kode verifikasi.</em>
|
||||
</p>
|
||||
|
||||
<div class="alert-warning" style="margin: 20px 0; padding: 15px; background-color: #fff3cd; border-left: 4px solid #ffc107;">
|
||||
<p style="margin: 0;"><strong>💡 Tips:</strong> Cek folder <em>Spam</em> atau <em>Promotions</em> jika email tidak muncul di inbox dalam 1-2 menit.</p>
|
||||
</div>
|
||||
|
||||
<blockquote class="alert-info">
|
||||
<strong>ℹ️ Info:</strong> Jika Anda tidak merasa mendaftar di {platform_name}, abaikan email ini dengan aman.
|
||||
</blockquote>
|
||||
---'
|
||||
WHERE key = 'auth_email_verification';
|
||||
|
||||
-- Return success message
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Auth email template updated with improved copywriting and dedicated page link';
|
||||
END $$;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Update order_created email template to remove QR code
|
||||
-- QR code is now displayed on the order detail page instead
|
||||
|
||||
UPDATE notification_templates
|
||||
SET
|
||||
email_subject = 'Konfirmasi Pesanan - Order #{order_id}',
|
||||
email_body_html = '
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #333;">Konfirmasi Pesanan</h2>
|
||||
|
||||
<p>Halo {nama},</p>
|
||||
|
||||
<p>Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:</p>
|
||||
|
||||
<!-- Order Summary Section -->
|
||||
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Order ID:</strong> {order_id}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Tanggal:</strong> {tanggal_pesanan}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Produk:</strong> {produk}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Metode Pembayaran:</strong> {metode_pembayaran}
|
||||
</p>
|
||||
|
||||
<p style="margin: 15px 0 5px 0; font-size: 16px; font-weight: bold; color: #000;">
|
||||
Total: {total}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin: 30px 0; padding: 20px; background-color: #f5f5f5; border-radius: 8px; text-align: center;">
|
||||
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
|
||||
Silakan selesaikan pembayaran Anda dengan mengklik tombol di bawah:
|
||||
</p>
|
||||
|
||||
<a href="{payment_link}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
Bayar Sekarang
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px;">
|
||||
Jika Anda memiliki pertanyaan, jangan ragu untuk menghubungi kami.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px;">
|
||||
Terima kasih,<br>
|
||||
Tim {platform_name}
|
||||
</p>
|
||||
</div>
|
||||
',
|
||||
updated_at = NOW()
|
||||
WHERE key = 'order_created';
|
||||
|
||||
-- Verify the update
|
||||
SELECT
|
||||
key,
|
||||
email_subject,
|
||||
is_active,
|
||||
LEFT(email_body_html, 100) as body_preview,
|
||||
updated_at
|
||||
FROM notification_templates
|
||||
WHERE key = 'order_created';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- ============================================================================
|
||||
-- Add platform_url column to platform_settings
|
||||
-- ============================================================================
|
||||
|
||||
-- Add platform_url column if it doesn't exist
|
||||
ALTER TABLE platform_settings
|
||||
ADD COLUMN IF NOT EXISTS platform_url TEXT;
|
||||
|
||||
-- Set default value if null
|
||||
UPDATE platform_settings
|
||||
SET platform_url = 'https://access-hub.com'
|
||||
WHERE platform_url IS NULL;
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON COLUMN platform_settings.platform_url IS 'Base URL of the platform (used for email links)';
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Add test_email template for "Uji Coba Email" button in Integrasi tab
|
||||
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
|
||||
VALUES (
|
||||
'test_email',
|
||||
'Test Email',
|
||||
'Email Test - {platform_name}',
|
||||
'
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #333;">Email Test - {platform_name}</h2>
|
||||
|
||||
<p>Halo,</p>
|
||||
|
||||
<p>Ini adalah email tes dari sistem <strong>{platform_name}</strong>.</p>
|
||||
|
||||
<div style="margin: 20px 0; padding: 15px; background-color: #f0f0f0; border-left: 4px solid #000;">
|
||||
<p style="margin: 0; font-size: 14px;">
|
||||
<strong>✓ Konfigurasi email berhasil!</strong><br>
|
||||
Email Anda telah terkirim dengan benar menggunakan provider: <strong>Mailketing</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
Jika Anda menerima email ini, berarti konfigurasi email sudah benar.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px;">
|
||||
Terima kasih,<br>
|
||||
Tim {platform_name}
|
||||
</p>
|
||||
</div>
|
||||
',
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
email_subject = EXCLUDED.email_subject,
|
||||
email_body_html = EXCLUDED.email_body_html,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Verify the template
|
||||
SELECT
|
||||
key,
|
||||
name,
|
||||
email_subject,
|
||||
is_active
|
||||
FROM notification_templates
|
||||
WHERE key = 'test_email';
|
||||
@@ -0,0 +1,197 @@
|
||||
-- ============================================================================
|
||||
-- Fix Email Templates: Use Short Order ID and Add Missing Links
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. Fix order_created template - use short order_id and fix subject
|
||||
UPDATE notification_templates
|
||||
SET
|
||||
email_subject = 'Konfirmasi Pesanan - #{order_id_short}',
|
||||
email_body_html = '---
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #333;">Konfirmasi Pesanan</h2>
|
||||
|
||||
<p>Halo {nama},</p>
|
||||
|
||||
<p>Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:</p>
|
||||
|
||||
<!-- Order Summary Section -->
|
||||
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Order ID:</strong> #{order_id_short}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Tanggal:</strong> {tanggal_pesanan}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Produk:</strong> {produk}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Metode Pembayaran:</strong> {metode_pembayaran}
|
||||
</p>
|
||||
|
||||
<p style="margin: 15px 0 5px 0; font-size: 16px; font-weight: bold; color: #000;">
|
||||
Total: {total}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin: 30px 0; padding: 20px; background-color: #f5f5f5; border-radius: 8px; text-align: center;">
|
||||
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
|
||||
Silakan selesaikan pembayaran Anda dengan mengklik tombol di bawah:
|
||||
</p>
|
||||
|
||||
<a href="{payment_link}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
Bayar Sekarang
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px;">
|
||||
Jika Anda memiliki pertanyaan, jangan ragu untuk menghubungi kami.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px;">
|
||||
Terima kasih,<br>
|
||||
Tim {platform_name}
|
||||
</p>
|
||||
</div>
|
||||
---',
|
||||
updated_at = NOW()
|
||||
WHERE key = 'order_created';
|
||||
|
||||
-- 2. Create or update payment_success template
|
||||
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
|
||||
VALUES (
|
||||
'payment_success',
|
||||
'Payment Success Email',
|
||||
'Pembayaran Berhasil - Order #{order_id_short}',
|
||||
'---
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #28a745;">Pembayaran Berhasil! ✓</h2>
|
||||
|
||||
<p>Halo {nama},</p>
|
||||
|
||||
<p>Terima kasih! Pembayaran Anda telah berhasil dikonfirmasi.</p>
|
||||
|
||||
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Order ID:</strong> #{order_id_short}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Tanggal:</strong> {tanggal_pesanan}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Produk:</strong> {produk}
|
||||
</p>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>Metode Pembayaran:</strong> {metode_pembayaran}
|
||||
</p>
|
||||
|
||||
<p style="margin: 15px 0 5px 0; font-size: 16px; font-weight: bold; color: #28a745;">
|
||||
Total: {total}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border-radius: 8px; text-align: center;">
|
||||
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
|
||||
Akses ke produk Anda sudah aktif! Klik tombol di bawah untuk mulai belajar:
|
||||
</p>
|
||||
|
||||
<a href="{link_akses}" style="display: inline-block; padding: 12px 24px; background-color: #28a745; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
Akses Sekarang
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
Jika Anda mengalami masalah saat mengakses produk, jangan ragu untuk menghubungi kami.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px;">
|
||||
Selamat belajar!<br>
|
||||
Tim {platform_name}
|
||||
</p>
|
||||
</div>
|
||||
---',
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
email_subject = EXCLUDED.email_subject,
|
||||
email_body_html = EXCLUDED.email_body_html,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = NOW();
|
||||
|
||||
-- 3. Create or update access_granted template
|
||||
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
|
||||
VALUES (
|
||||
'access_granted',
|
||||
'Access Granted Email',
|
||||
'Akses Produk Diberikan - {produk}',
|
||||
'---
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #28a745;">Akses Produk Aktif! 🎉</h2>
|
||||
|
||||
<p>Halo {nama},</p>
|
||||
|
||||
<p>Selamat! Akses ke produk Anda telah diaktifkan.</p>
|
||||
|
||||
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border: 1px solid #b3d9ff; border-radius: 8px;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Produk Anda:</h3>
|
||||
|
||||
<p style="margin: 5px 0; font-size: 14px;">
|
||||
<strong>{produk}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border-radius: 8px; text-align: center;">
|
||||
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
|
||||
Mulai belajar sekarang dengan mengklik tombol di bawah:
|
||||
</p>
|
||||
|
||||
<a href="{link_akses}" style="display: inline-block; padding: 12px 24px; background-color: #28a745; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
Akses Sekarang
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
Nikmati pembelajaran Anda! Jika ada pertanyaan, jangan ragu untuk menghubungi kami.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px;">
|
||||
Happy learning!<br>
|
||||
Tim {platform_name}
|
||||
</p>
|
||||
</div>
|
||||
---',
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
email_subject = EXCLUDED.email_subject,
|
||||
email_body_html = EXCLUDED.email_body_html,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Verify updates
|
||||
SELECT
|
||||
key,
|
||||
email_subject,
|
||||
is_active,
|
||||
updated_at
|
||||
FROM notification_templates
|
||||
WHERE key IN ('order_created', 'payment_success', 'access_granted')
|
||||
ORDER BY key;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Enable public read access to bootcamp curriculum for product detail pages
|
||||
-- This allows unauthenticated users to see the curriculum preview
|
||||
|
||||
-- Enable RLS on bootcamp_modules (if not already enabled)
|
||||
ALTER TABLE bootcamp_modules ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Enable RLS on bootcamp_lessons (if not already enabled)
|
||||
ALTER TABLE bootcamp_lessons ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Drop existing policies if they exist (to avoid conflicts)
|
||||
DROP POLICY IF EXISTS "Public can view bootcamp modules" ON bootcamp_modules;
|
||||
DROP POLICY IF EXISTS "Public can view bootcamp lessons" ON bootcamp_lessons;
|
||||
DROP POLICY IF EXISTS "Authenticated can view bootcamp modules" ON bootcamp_modules;
|
||||
DROP POLICY IF EXISTS "Authenticated can view bootcamp lessons" ON bootcamp_lessons;
|
||||
|
||||
-- Create policy for public read access to bootcamp_modules
|
||||
-- Anyone can view modules to see curriculum preview
|
||||
CREATE POLICY "Public can view bootcamp modules"
|
||||
ON bootcamp_modules
|
||||
FOR SELECT
|
||||
TO public, authenticated
|
||||
USING (true);
|
||||
|
||||
-- Create policy for public read access to bootcamp_lessons
|
||||
-- Anyone can view lessons to see curriculum preview
|
||||
CREATE POLICY "Public can view bootcamp lessons"
|
||||
ON bootcamp_lessons
|
||||
FOR SELECT
|
||||
TO public, authenticated
|
||||
USING (true);
|
||||
|
||||
-- Comment explaining the policies
|
||||
COMMENT ON POLICY "Public can view bootcamp modules" ON bootcamp_modules IS 'Allows public read access to bootcamp curriculum for product detail pages';
|
||||
COMMENT ON POLICY "Public can view bootcamp lessons" ON bootcamp_lessons IS 'Allows public read access to bootcamp lessons for curriculum preview';
|
||||
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 $$;
|
||||
|
||||
@@ -218,11 +218,7 @@ export class EmailTemplateRenderer {
|
||||
<tr>
|
||||
<td align="left" style="font-size: 12px; line-height: 18px; font-family: monospace; color: #555;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: bold;">{{brandName}}</p>
|
||||
<p style="margin: 0 0 15px 0;">Email ini dikirim otomatis. Jangan membalas email ini.</p>
|
||||
<p style="margin: 0;">
|
||||
<a href="#" style="color: #000; text-decoration: underline;">Ubah Preferensi</a> |
|
||||
<a href="#" style="color: #000; text-decoration: underline;">Unsubscribe</a>
|
||||
</p>
|
||||
<p style="margin: 0;">Email ini dikirim otomatis. Jangan membalas email ini.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user