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:
dwindown
2026-01-03 18:02:25 +07:00
parent 4f9a6f4ae3
commit 053465afa3
21 changed files with 1381 additions and 948 deletions

View File

@@ -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 />} />

View File

@@ -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>

View File

@@ -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,
},
},
});

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
View 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>
);
}

View File

@@ -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>
);

View 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" } }
);
}
});

View File

@@ -309,18 +309,30 @@ async function sendNotification(
return;
}
// Send email via Mailketing
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_ANON_KEY")}`,
},
body: JSON.stringify({
to: data.email,
subject: template.email_subject,
html: template.email_body_html,
shortcodeData: data,
}),
});
// 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_SERVICE_ROLE_KEY")}`,
},
body: JSON.stringify({
template_key: templateKey,
recipient_email: data.email,
recipient_name: data.user_name || data.nama,
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);
}
}

View File

@@ -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,
user_id: user_id,
email: email,
otp_code: otpCode,
expires_at: expiresAt.toISOString(),
expires_at: expiresAt,
});
if (otpError) {
console.error('Error storing OTP:', otpError);
throw new Error(`Failed to store OTP: ${otpError.message}`);
if (insertError) {
console.error('Error storing OTP:', insertError);
throw new Error(`Failed to store OTP: ${insertError.message}`);
}
// Get notification settings
const { data: settings, error: settingsError } = await supabase
.from('notification_settings')
.select('*')
.single();
console.log(`OTP generated and stored: ${otpCode}, expires at: ${expiresAt}`);
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',
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);
});
// 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');
}
// 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),
});
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,

View File

@@ -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" } }
);
}
});

View File

@@ -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),

View File

@@ -1,7 +1,6 @@
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";
import QRCode from 'https://esm.sh/qrcode@1.5.3';
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -259,44 +258,29 @@ 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,
};
// Special handling for order_created: generate QR code image
if (template_key === 'order_created' && allVariables.qr_string) {
console.log('[SEND-NOTIFICATION] Generating QR code for order_created email');
try {
const qrDataUrl = await QRCode.toDataURL(allVariables.qr_string, {
width: 300,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' }
});
allVariables.qr_code_image = qrDataUrl;
console.log('[SEND-NOTIFICATION] QR code generated successfully');
} catch (qrError) {
console.error('[SEND-NOTIFICATION] Failed to generate QR code:', qrError);
// Continue without QR code - don't fail the email
allVariables.qr_code_image = '';
}
}
const subject = replaceVariables(template.email_subject || template.subject || "", allVariables);
const htmlContent = replaceVariables(template.email_body_html || template.body_html || template.body_text || "", allVariables);
@@ -304,67 +288,30 @@ serve(async (req: Request): Promise<Response> => {
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 || "mailketing";
const provider = platformSettings.integration_email_provider || "mailketing";
console.log(`Sending email via ${provider} to ${recipient_email}`);
switch (provider) {
case "mailketing":
const mailketingToken = settings.mailketing_api_token || settings.api_token;
const mailketingToken = platformSettings.integration_email_api_token;
if (!mailketingToken) throw new Error("Mailketing API token not configured");
await sendViaMailketing(emailPayload, mailketingToken);
break;
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);
break;
default:
throw new Error(`Unknown email provider: ${provider}`);
throw new Error(`Unknown email provider: ${provider}. Only 'mailketing' is supported.`);
}
// Log notification

View File

@@ -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" } }
);
}
});

View File

@@ -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 $$;

View File

@@ -1,5 +1,5 @@
-- Update order_created email template to include QR code
-- This migration adds the QR code section to the order confirmation email
-- Update order_created email template to remove QR code
-- QR code is now displayed on the order detail page instead
UPDATE notification_templates
SET
@@ -12,28 +12,6 @@ SET
<p>Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:</p>
<!-- QR Code Section -->
<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>
<!-- QR Code Image -->
<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>
<!-- 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>
@@ -59,6 +37,16 @@ SET
</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>

View File

@@ -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)';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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> &nbsp;|&nbsp;
<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>