Fix email system and implement OTP confirmation flow
Email System Fixes: - Fix email sending after payment: handle-order-paid now calls send-notification instead of send-email-v2 directly, properly processing template variables - Fix order_created email timing: sent immediately after order creation, before payment QR code generation - Update email templates to use short order ID (8 chars) instead of full UUID - Add working "Akses Sekarang" buttons to payment_success and access_granted emails - Add platform_url column to platform_settings for email links OTP Verification Flow: - Create dedicated /confirm-otp page for users who close registration modal - Add link in checkout modal and email to dedicated OTP page - Update OTP email template with better copywriting and dedicated page link - Fix send-auth-otp to fetch platform settings for dynamic brand_name and platform_url - Auto-login users after OTP verification in checkout flow Admin Features: - Add delete user functionality with cascade deletion of all related data - Update IntegrasiTab to read/write email settings from platform_settings only - Add test email template for email configuration testing Cleanup: - Remove obsolete send-consultation-reminder and send-test-email functions - Update send-email-v2 to read email config from platform_settings - Remove footer links (Ubah Preferensi/Unsubscribe) from email templates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { CartProvider } from "@/contexts/CartContext";
|
||||
import { BrandingProvider } from "@/hooks/useBranding";
|
||||
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";
|
||||
@@ -53,6 +54,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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -494,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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -107,37 +107,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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -25,6 +25,7 @@ export default function Auth() {
|
||||
const [isResendOTP, setIsResendOTP] = useState(false); // Track if this is resend OTP for existing user
|
||||
const { signIn, signUp, user, sendAuthOTP, verifyAuthOTP, getUserByEmail } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -100,7 +101,9 @@ export default function Auth() {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
setLoading(false);
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
// Get redirect from URL state or use default
|
||||
const redirectTo = (location.state as any)?.redirectTo || '/dashboard';
|
||||
navigate(redirectTo);
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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";
|
||||
@@ -11,7 +11,8 @@ 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 => {
|
||||
@@ -24,7 +25,7 @@ type CheckoutStep = "cart" | "payment";
|
||||
|
||||
export default function Checkout() {
|
||||
const { items, removeItem, clearCart, total } = useCart();
|
||||
const { user, signIn, signUp } = useAuth();
|
||||
const { user, signIn, signUp, sendAuthOTP, verifyAuthOTP } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -36,6 +37,10 @@ export default function Checkout() {
|
||||
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();
|
||||
@@ -49,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;
|
||||
}
|
||||
|
||||
@@ -99,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(", ");
|
||||
|
||||
@@ -116,44 +158,6 @@ export default function Checkout() {
|
||||
throw new Error(paymentError.message || 'Gagal membuat pembayaran');
|
||||
}
|
||||
|
||||
// Send order_created email with QR code (wait for completion before navigating)
|
||||
console.log('[CHECKOUT] About to send order_created email for order:', order.id);
|
||||
console.log('[CHECKOUT] User email:', user.email);
|
||||
console.log('[CHECKOUT] Payment data QR string:', paymentData?.qr_string);
|
||||
|
||||
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,
|
||||
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}`,
|
||||
qr_string: paymentData?.qr_string || '',
|
||||
qr_expiry_time: paymentData?.expired_at ? new Date(paymentData.expired_at).toLocaleString('id-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');
|
||||
|
||||
// Clear cart and redirect to order detail page to show QR code
|
||||
clearCart();
|
||||
navigate(`/orders/${order.id}`);
|
||||
@@ -212,25 +216,131 @@ export default function Checkout() {
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
const { error, data } = await signUp(authEmail, authPassword, authName);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Registrasi gagal",
|
||||
description: error.message || "Gagal membuat akun",
|
||||
variant: "destructive",
|
||||
});
|
||||
setAuthLoading(false);
|
||||
} else {
|
||||
toast({
|
||||
title: "Registrasi berhasil",
|
||||
description: "Silakan cek email untuk verifikasi akun Anda",
|
||||
});
|
||||
setAuthModalOpen(false);
|
||||
setAuthLoading(false);
|
||||
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">
|
||||
@@ -311,116 +421,190 @@ export default function Checkout() {
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Dialog open={authModalOpen} onOpenChange={setAuthModalOpen}>
|
||||
<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">
|
||||
<DialogContent className="sm:max-w-md" onPointerDownOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Login atau Daftar</DialogTitle>
|
||||
<DialogTitle>{showOTP ? "Verifikasi Email" : "Login atau Daftar"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
|
||||
{!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>
|
||||
)}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,83 @@ 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 consulting_slots
|
||||
await supabase.from("consulting_slots").delete().eq("user_id", userId);
|
||||
|
||||
// Step 7: Delete calendar_events
|
||||
await supabase.from("calendar_events").delete().eq("user_id", userId);
|
||||
|
||||
// Step 8: Delete user_roles
|
||||
await supabase.from("user_roles").delete().eq("user_id", userId);
|
||||
|
||||
// Step 9: Delete profile
|
||||
await supabase.from("profiles").delete().eq("id", userId);
|
||||
|
||||
// Step 10: 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: any) {
|
||||
console.error('Delete member error:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Gagal menghapus member",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
@@ -243,6 +333,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 +388,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 +443,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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user