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 { BrandingProvider } from "@/hooks/useBranding";
import Index from "./pages/Index"; import Index from "./pages/Index";
import Auth from "./pages/Auth"; import Auth from "./pages/Auth";
import ConfirmOTP from "./pages/ConfirmOTP";
import Products from "./pages/Products"; import Products from "./pages/Products";
import ProductDetail from "./pages/ProductDetail"; import ProductDetail from "./pages/ProductDetail";
import Checkout from "./pages/Checkout"; import Checkout from "./pages/Checkout";
@@ -53,6 +54,7 @@ const App = () => (
<Routes> <Routes>
<Route path="/" element={<Index />} /> <Route path="/" element={<Index />} />
<Route path="/auth" element={<Auth />} /> <Route path="/auth" element={<Auth />} />
<Route path="/confirm-otp" element={<ConfirmOTP />} />
<Route path="/products" element={<Products />} /> <Route path="/products" element={<Products />} />
<Route path="/products/:slug" element={<ProductDetail />} /> <Route path="/products/:slug" element={<ProductDetail />} />
<Route path="/checkout" element={<Checkout />} /> <Route path="/checkout" element={<Checkout />} />

View File

@@ -20,14 +20,12 @@ interface IntegrationSettings {
google_oauth_config?: string; google_oauth_config?: string;
integration_email_provider: string; integration_email_provider: string;
integration_email_api_base_url: 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_privacy_url: string;
integration_terms_url: string; integration_terms_url: string;
integration_n8n_test_mode: boolean; integration_n8n_test_mode: boolean;
// Mailketing specific settings
provider: 'mailketing' | 'smtp';
api_token: string;
from_name: string;
from_email: string;
} }
const emptySettings: IntegrationSettings = { const emptySettings: IntegrationSettings = {
@@ -37,13 +35,12 @@ const emptySettings: IntegrationSettings = {
integration_google_calendar_id: '', integration_google_calendar_id: '',
integration_email_provider: 'mailketing', integration_email_provider: 'mailketing',
integration_email_api_base_url: '', integration_email_api_base_url: '',
integration_email_api_token: '',
integration_email_from_name: '',
integration_email_from_email: '',
integration_privacy_url: '/privacy', integration_privacy_url: '/privacy',
integration_terms_url: '/terms', integration_terms_url: '/terms',
integration_n8n_test_mode: false, integration_n8n_test_mode: false,
provider: 'mailketing',
api_token: '',
from_name: '',
from_email: '',
}; };
export function IntegrasiTab() { export function IntegrasiTab() {
@@ -64,12 +61,6 @@ export function IntegrasiTab() {
.select('*') .select('*')
.single(); .single();
// Fetch email provider settings from notification_settings
const { data: emailData } = await supabase
.from('notification_settings')
.select('*')
.single();
if (platformData) { if (platformData) {
setSettings({ setSettings({
id: platformData.id, id: platformData.id,
@@ -80,14 +71,12 @@ export function IntegrasiTab() {
google_oauth_config: platformData.google_oauth_config || '', google_oauth_config: platformData.google_oauth_config || '',
integration_email_provider: platformData.integration_email_provider || 'mailketing', integration_email_provider: platformData.integration_email_provider || 'mailketing',
integration_email_api_base_url: platformData.integration_email_api_base_url || '', 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_privacy_url: platformData.integration_privacy_url || '/privacy',
integration_terms_url: platformData.integration_terms_url || '/terms', integration_terms_url: platformData.integration_terms_url || '/terms',
integration_n8n_test_mode: platformData.integration_n8n_test_mode || false, 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); setLoading(false);
@@ -97,7 +86,7 @@ export function IntegrasiTab() {
setSaving(true); setSaving(true);
try { try {
// Save platform settings // Save platform settings (includes email settings)
const platformPayload = { const platformPayload = {
integration_n8n_base_url: settings.integration_n8n_base_url, integration_n8n_base_url: settings.integration_n8n_base_url,
integration_whatsapp_number: settings.integration_whatsapp_number, integration_whatsapp_number: settings.integration_whatsapp_number,
@@ -106,6 +95,9 @@ export function IntegrasiTab() {
google_oauth_config: settings.google_oauth_config, google_oauth_config: settings.google_oauth_config,
integration_email_provider: settings.integration_email_provider, integration_email_provider: settings.integration_email_provider,
integration_email_api_base_url: settings.integration_email_api_base_url, 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_privacy_url: settings.integration_privacy_url,
integration_terms_url: settings.integration_terms_url, integration_terms_url: settings.integration_terms_url,
integration_n8n_test_mode: settings.integration_n8n_test_mode, 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_google_calendar_id: settings.integration_google_calendar_id,
integration_email_provider: settings.integration_email_provider, integration_email_provider: settings.integration_email_provider,
integration_email_api_base_url: settings.integration_email_api_base_url, 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_privacy_url: settings.integration_privacy_url,
integration_terms_url: settings.integration_terms_url, integration_terms_url: settings.integration_terms_url,
integration_n8n_test_mode: settings.integration_n8n_test_mode, 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' }); toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' });
} catch (error: any) { } catch (error: any) {
toast({ title: 'Error', description: error.message, variant: 'destructive' }); toast({ title: 'Error', description: error.message, variant: 'destructive' });
@@ -195,21 +162,50 @@ export function IntegrasiTab() {
setSendingTest(true); setSendingTest(true);
try { 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: { body: {
recipient: testEmail, template_key: 'test_email',
api_token: settings.api_token, recipient_email: testEmail,
from_name: settings.from_name, recipient_name: 'Admin',
from_email: settings.from_email, variables: {
subject: 'Test Email dari Access Hub', brand_name: brandName,
content: ` test_email: testEmail
<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>
`,
}, },
}); });
@@ -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" />; 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"> <div className="space-y-2">
<Label>Provider Email</Label> <Label>Provider Email</Label>
<Select <Select
value={settings.provider} value={settings.integration_email_provider}
onValueChange={(value: 'mailketing' | 'smtp') => setSettings({ ...settings, provider: value })} onValueChange={(value: 'mailketing') => setSettings({ ...settings, integration_email_provider: value })}
> >
<SelectTrigger className="border-2"> <SelectTrigger className="border-2">
<SelectValue placeholder="Pilih provider email" /> <SelectValue placeholder="Pilih provider email" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="mailketing">Mailketing</SelectItem> <SelectItem value="mailketing">Mailketing</SelectItem>
<SelectItem value="smtp">SMTP (Legacy)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{settings.provider === 'mailketing' && ( {settings.integration_email_provider === 'mailketing' && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
@@ -459,8 +454,8 @@ export function IntegrasiTab() {
</Label> </Label>
<Input <Input
type="password" type="password"
value={settings.api_token} value={settings.integration_email_api_token}
onChange={(e) => setSettings({ ...settings, api_token: e.target.value })} onChange={(e) => setSettings({ ...settings, integration_email_api_token: e.target.value })}
placeholder="Masukkan API token dari Mailketing" placeholder="Masukkan API token dari Mailketing"
className="border-2" className="border-2"
/> />
@@ -473,8 +468,8 @@ export function IntegrasiTab() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Nama Pengirim</Label> <Label>Nama Pengirim</Label>
<Input <Input
value={settings.from_name} value={settings.integration_email_from_name}
onChange={(e) => setSettings({ ...settings, from_name: e.target.value })} onChange={(e) => setSettings({ ...settings, integration_email_from_name: e.target.value })}
placeholder="Nama Bisnis" placeholder="Nama Bisnis"
className="border-2" className="border-2"
/> />
@@ -483,8 +478,8 @@ export function IntegrasiTab() {
<Label>Email Pengirim</Label> <Label>Email Pengirim</Label>
<Input <Input
type="email" type="email"
value={settings.from_email} value={settings.integration_email_from_email}
onChange={(e) => setSettings({ ...settings, from_email: e.target.value })} onChange={(e) => setSettings({ ...settings, integration_email_from_email: e.target.value })}
placeholder="info@domain.com" placeholder="info@domain.com"
className="border-2" className="border-2"
/> />
@@ -509,21 +504,6 @@ export function IntegrasiTab() {
</div> </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> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -494,37 +494,30 @@ export function NotifikasiTab() {
setTestingTemplate(template.id); setTestingTemplate(template.id);
try { try {
// Fetch email settings from notification_settings // Fetch platform settings to get brand name
const { data: emailData } = await supabase const { data: platformData } = await supabase
.from('notification_settings') .from('platform_settings')
.select('*') .select('brand_name')
.single(); .single();
if (!emailData || !emailData.api_token || !emailData.from_email) { const brandName = platformData?.brand_name || 'ACCESS HUB';
throw new Error('Konfigurasi email provider belum lengkap');
}
// Import EmailTemplateRenderer and ShortcodeProcessor // Import ShortcodeProcessor to get dummy data
const { EmailTemplateRenderer, ShortcodeProcessor } = await import('@/lib/email-templates/master-template'); const { ShortcodeProcessor } = await import('@/lib/email-templates/master-template');
// Process shortcodes and render with master template // Get default dummy data for all template variables
const processedSubject = ShortcodeProcessor.process(template.email_subject || ''); const dummyData = ShortcodeProcessor.getDummyData();
const processedContent = ShortcodeProcessor.process(template.email_body_html || '');
const fullHtml = EmailTemplateRenderer.render({
subject: processedSubject,
content: processedContent,
brandName: 'ACCESS HUB'
});
// Send test email using send-email-v2 // Send test email using send-notification (same as IntegrasiTab)
const { data, error } = await supabase.functions.invoke('send-email-v2', { const { data, error } = await supabase.functions.invoke('send-notification', {
body: { body: {
recipient: template.test_email, template_key: template.key,
api_token: emailData.api_token, recipient_email: template.test_email,
from_name: emailData.from_name, recipient_name: dummyData.nama,
from_email: emailData.from_email, variables: {
subject: processedSubject, ...dummyData,
content: fullHtml, platform_name: brandName,
},
}, },
}); });

View File

@@ -107,37 +107,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const sendAuthOTP = async (userId: string, email: string) => { const sendAuthOTP = async (userId: string, email: string) => {
try { try {
const { data: { session } } = await supabase.auth.getSession(); const { data, error } = await supabase.functions.invoke('send-auth-otp', {
const token = session?.access_token || import.meta.env.VITE_SUPABASE_ANON_KEY; body: { user_id: userId, email }
});
console.log('Sending OTP request', { userId, email, hasSession: !!session }); if (error) {
console.error('OTP request error:', error);
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);
return { return {
success: false, success: false,
message: `HTTP ${response.status}: ${errorText}` message: error.message || 'Failed to send OTP'
}; };
} }
const result = await response.json(); console.log('OTP result:', data);
console.log('OTP result:', result); return {
return result; success: data?.success || false,
message: data?.message || 'OTP sent successfully'
};
} catch (error: any) { } catch (error: any) {
console.error('Error sending OTP:', error); console.error('Error sending OTP:', error);
return { return {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; 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 [isResendOTP, setIsResendOTP] = useState(false); // Track if this is resend OTP for existing user
const { signIn, signUp, user, sendAuthOTP, verifyAuthOTP, getUserByEmail } = useAuth(); const { signIn, signUp, user, sendAuthOTP, verifyAuthOTP, getUserByEmail } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -100,7 +101,9 @@ export default function Auth() {
toast({ title: 'Error', description: error.message, variant: 'destructive' }); toast({ title: 'Error', description: error.message, variant: 'destructive' });
setLoading(false); setLoading(false);
} else { } else {
navigate('/dashboard'); // Get redirect from URL state or use default
const redirectTo = (location.state as any)?.redirectTo || '/dashboard';
navigate(redirectTo);
setLoading(false); setLoading(false);
} }
} else { } else {

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AppLayout } from "@/components/AppLayout"; import { AppLayout } from "@/components/AppLayout";
import { useCart } from "@/contexts/CartContext"; 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 { Input } from "@/components/ui/input";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import { formatIDR } from "@/lib/format"; 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 // Edge function base URL - configurable via env with sensible default
const getEdgeFunctionBaseUrl = (): string => { const getEdgeFunctionBaseUrl = (): string => {
@@ -24,7 +25,7 @@ type CheckoutStep = "cart" | "payment";
export default function Checkout() { export default function Checkout() {
const { items, removeItem, clearCart, total } = useCart(); const { items, removeItem, clearCart, total } = useCart();
const { user, signIn, signUp } = useAuth(); const { user, signIn, signUp, sendAuthOTP, verifyAuthOTP } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -36,6 +37,10 @@ export default function Checkout() {
const [authEmail, setAuthEmail] = useState(""); const [authEmail, setAuthEmail] = useState("");
const [authPassword, setAuthPassword] = useState(""); const [authPassword, setAuthPassword] = useState("");
const [authName, setAuthName] = 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 checkPaymentStatus = async (oid: string) => {
const { data: order } = await supabase.from("orders").select("payment_status").eq("id", oid).single(); 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 () => { const handleCheckout = async () => {
if (!user) { if (!user) {
toast({ title: "Login diperlukan", description: "Silakan login untuk melanjutkan pembayaran" }); 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; return;
} }
@@ -99,6 +105,42 @@ export default function Checkout() {
const { error: itemsError } = await supabase.from("order_items").insert(orderItems); const { error: itemsError } = await supabase.from("order_items").insert(orderItems);
if (itemsError) throw new Error("Gagal menambahkan item order"); 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 // Build description from product titles
const productTitles = items.map(item => item.title).join(", "); const productTitles = items.map(item => item.title).join(", ");
@@ -116,44 +158,6 @@ export default function Checkout() {
throw new Error(paymentError.message || 'Gagal membuat pembayaran'); 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 // Clear cart and redirect to order detail page to show QR code
clearCart(); clearCart();
navigate(`/orders/${order.id}`); navigate(`/orders/${order.id}`);
@@ -212,25 +216,131 @@ export default function Checkout() {
} }
setAuthLoading(true); setAuthLoading(true);
const { error, data } = await signUp(authEmail, authPassword, authName);
if (error) { try {
toast({ const { data, error } = await signUp(authEmail, authPassword, authName);
title: "Registrasi gagal",
description: error.message || "Gagal membuat akun", if (error) {
variant: "destructive", toast({
}); title: "Registrasi gagal",
setAuthLoading(false); description: error.message || "Gagal membuat akun",
} else { variant: "destructive",
toast({ });
title: "Registrasi berhasil", setAuthLoading(false);
description: "Silakan cek email untuk verifikasi akun Anda", return;
}); }
setAuthModalOpen(false);
setAuthLoading(false); 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 ( return (
<AppLayout> <AppLayout>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -311,116 +421,190 @@ export default function Checkout() {
)} )}
</Button> </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> <DialogTrigger asChild>
<Button className="w-full shadow-sm"> <Button className="w-full shadow-sm">
Login atau Daftar untuk Checkout Login atau Daftar untuk Checkout
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md" onPointerDownOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Login atau Daftar</DialogTitle> <DialogTitle>{showOTP ? "Verifikasi Email" : "Login atau Daftar"}</DialogTitle>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="login" className="w-full">
<TabsList className="grid w-full grid-cols-2"> {!showOTP ? (
<TabsTrigger value="login">Login</TabsTrigger> <Tabs defaultValue="login" className="w-full">
<TabsTrigger value="register">Daftar</TabsTrigger> <TabsList className="grid w-full grid-cols-2">
</TabsList> <TabsTrigger value="login">Login</TabsTrigger>
<TabsContent value="login"> <TabsTrigger value="register">Daftar</TabsTrigger>
<form onSubmit={handleLogin} className="space-y-4 mt-4"> </TabsList>
<div className="space-y-2"> <TabsContent value="login">
<label htmlFor="login-email" className="text-sm font-medium"> <form onSubmit={handleLogin} className="space-y-4 mt-4">
Email <div className="space-y-2">
</label> <label htmlFor="login-email" className="text-sm font-medium">
<Input Email
id="login-email" </label>
type="email" <Input
placeholder="nama@email.com" id="login-email"
value={authEmail} type="email"
onChange={(e) => setAuthEmail(e.target.value)} placeholder="nama@email.com"
required value={authEmail}
/> onChange={(e) => setAuthEmail(e.target.value)}
</div> required
<div className="space-y-2"> />
<label htmlFor="login-password" className="text-sm font-medium"> </div>
Password <div className="space-y-2">
</label> <label htmlFor="login-password" className="text-sm font-medium">
<Input Password
id="login-password" </label>
type="password" <Input
placeholder="••••••••" id="login-password"
value={authPassword} type="password"
onChange={(e) => setAuthPassword(e.target.value)} placeholder="••••••••"
required value={authPassword}
/> onChange={(e) => setAuthPassword(e.target.value)}
</div> required
<Button type="submit" className="w-full" disabled={authLoading}> />
{authLoading ? ( </div>
<> <Button type="submit" className="w-full" disabled={authLoading}>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> {authLoading ? (
Memproses... <>
</> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : ( Memproses...
"Login" </>
)} ) : (
</Button> "Login"
</form> )}
</TabsContent> </Button>
<TabsContent value="register"> </form>
<form onSubmit={handleRegister} className="space-y-4 mt-4"> </TabsContent>
<div className="space-y-2"> <TabsContent value="register">
<label htmlFor="register-name" className="text-sm font-medium"> <form onSubmit={handleRegister} className="space-y-4 mt-4">
Nama Lengkap <div className="space-y-2">
</label> <label htmlFor="register-name" className="text-sm font-medium">
<Input Nama Lengkap
id="register-name" </label>
type="text" <Input
placeholder="John Doe" id="register-name"
value={authName} type="text"
onChange={(e) => setAuthName(e.target.value)} placeholder="John Doe"
required value={authName}
/> onChange={(e) => setAuthName(e.target.value)}
</div> required
<div className="space-y-2"> />
<label htmlFor="register-email" className="text-sm font-medium"> </div>
Email <div className="space-y-2">
</label> <label htmlFor="register-email" className="text-sm font-medium">
<Input Email
id="register-email" </label>
type="email" <Input
placeholder="nama@email.com" id="register-email"
value={authEmail} type="email"
onChange={(e) => setAuthEmail(e.target.value)} placeholder="nama@email.com"
required value={authEmail}
/> onChange={(e) => setAuthEmail(e.target.value)}
</div> required
<div className="space-y-2"> />
<label htmlFor="register-password" className="text-sm font-medium"> </div>
Password (minimal 6 karakter) <div className="space-y-2">
</label> <label htmlFor="register-password" className="text-sm font-medium">
<Input Password (minimal 6 karakter)
id="register-password" </label>
type="password" <Input
placeholder="••••••••" id="register-password"
value={authPassword} type="password"
onChange={(e) => setAuthPassword(e.target.value)} placeholder="••••••••"
required value={authPassword}
minLength={6} onChange={(e) => setAuthPassword(e.target.value)}
/> required
</div> minLength={6}
<Button type="submit" className="w-full" disabled={authLoading}> />
{authLoading ? ( </div>
<> <Button type="submit" className="w-full" disabled={authLoading}>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> {authLoading ? (
Memproses... <>
</> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : ( Memproses...
"Daftar" </>
)} ) : (
</Button> "Daftar"
</form> )}
</TabsContent> </Button>
</Tabs> </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> </DialogContent>
</Dialog> </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 { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { formatDateTime } from "@/lib/format"; 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 { toast } from "@/hooks/use-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Member { interface Member {
id: string; id: string;
@@ -39,6 +49,9 @@ export default function AdminMembers() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filterRole, setFilterRole] = useState<string>('all'); const [filterRole, setFilterRole] = useState<string>('all');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => { useEffect(() => {
if (!authLoading) { 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) { if (authLoading || loading) {
return ( return (
<AppLayout> <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" />} {adminIds.has(member.id) ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
</Button> </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> </TableCell>
</TableRow> </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) ? <ShieldOff className="w-4 h-4 mr-1" /> : <Shield className="w-4 h-4 mr-1" />}
{adminIds.has(member.id) ? "Hapus Admin" : "Jadikan Admin"} {adminIds.has(member.id) ? "Hapus Admin" : "Jadikan Admin"}
</Button> </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> </div>
</div> </div>
@@ -334,6 +443,57 @@ export default function AdminMembers() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </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> </div>
</AppLayout> </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; return;
} }
// Send email via Mailketing // Send email via send-notification (which will process shortcodes and call send-email-v2)
await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email-v2`, { try {
method: "POST", const notificationResponse = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-notification`, {
headers: { method: "POST",
"Content-Type": "application/json", headers: {
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`, "Content-Type": "application/json",
}, "Authorization": `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
body: JSON.stringify({ },
to: data.email, body: JSON.stringify({
subject: template.email_subject, template_key: templateKey,
html: template.email_body_html, recipient_email: data.email,
shortcodeData: data, 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 { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
const corsHeaders = { const corsHeaders = {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@@ -12,11 +11,6 @@ interface SendOTPRequest {
email: string; email: string;
} }
// Generate 6-digit OTP code
function generateOTP(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
serve(async (req: Request) => { serve(async (req: Request) => {
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders }); 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 // Initialize Supabase client with service role
const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey, { const supabase = createClient(supabaseUrl, supabaseServiceKey);
auth: {
autoRefreshToken: false,
persistSession: false
}
});
// Generate OTP code // Fetch platform settings for brand name and URL
const otpCode = generateOTP(); const { data: platformSettings } = await supabase
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now .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 // Store OTP in database
const { error: otpError } = await supabase const { error: insertError } = await supabase
.from('auth_otps') .from('auth_otps')
.insert({ .insert({
user_id, user_id: user_id,
email, email: email,
otp_code: otpCode, otp_code: otpCode,
expires_at: expiresAt.toISOString(), expires_at: expiresAt,
}); });
if (otpError) { if (insertError) {
console.error('Error storing OTP:', otpError); console.error('Error storing OTP:', insertError);
throw new Error(`Failed to store OTP: ${otpError.message}`); throw new Error(`Failed to store OTP: ${insertError.message}`);
} }
// Get notification settings console.log(`OTP generated and stored: ${otpCode}, expires at: ${expiresAt}`);
const { data: settings, error: settingsError } = await supabase
.from('notification_settings')
.select('*')
.single();
if (settingsError || !settings) { // Send OTP email using send-notification
console.error('Error fetching notification settings:', settingsError); const notificationUrl = `${supabaseUrl}/functions/v1/send-notification`;
throw new Error('Notification settings not configured'); const notificationResponse = await fetch(notificationUrl, {
}
// 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`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${supabaseServiceKey}`, 'Authorization': `Bearer ${supabaseServiceKey}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
recipient: email, template_key: 'auth_email_verification',
api_token: apiToken, recipient_email: email,
from_name: settings.from_name || brandName, recipient_name: email.split('@')[0],
from_email: settings.from_email || 'noreply@example.com', variables: {
subject: subject, nama: email.split('@')[0],
content: htmlBody, otp_code: otpCode,
email: email,
user_id: user_id,
expiry_minutes: '15',
platform_name: platformName,
platform_url: platformUrl
}
}), }),
}); });
if (!emailResponse.ok) { if (!notificationResponse.ok) {
const errorText = await emailResponse.text(); const errorText = await notificationResponse.text();
console.error('Email send error:', emailResponse.status, errorText); console.error('Error sending notification email:', notificationResponse.status, errorText);
throw new Error(`Failed to send email: ${emailResponse.status} ${errorText}`); throw new Error(`Failed to send OTP email: ${notificationResponse.status} ${errorText}`);
} }
const emailResult = await emailResponse.json(); const notificationResult = await notificationResponse.json();
console.log('Email sent successfully:', emailResult); console.log('Notification sent successfully:', notificationResult);
// Note: notification_logs table doesn't exist, skipping logging
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
message: 'OTP sent successfully' message: "OTP sent successfully"
}), }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
); );
} catch (error: any) { } catch (error: any) {
console.error("Error sending OTP:", error); console.error("Error sending OTP:", error);
// Note: notification_logs table doesn't exist, skipping error logging
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: false, 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 { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = { const corsHeaders = {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@@ -7,22 +8,24 @@ const corsHeaders = {
interface EmailRequest { interface EmailRequest {
recipient: string; recipient: string;
api_token: string;
from_name: string;
from_email: string;
subject: string; subject: string;
content: string; content: string;
} }
// Send via Mailketing API // Send via Mailketing API
async function sendViaMailketing(request: EmailRequest): Promise<{ success: boolean; message: string }> { async function sendViaMailketing(
const { recipient, api_token, from_name, from_email, subject, content } = request; 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) // Build form-encoded body (http_build_query format)
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('api_token', api_token); params.append('api_token', apiToken);
params.append('from_name', from_name); params.append('from_name', fromName);
params.append('from_email', from_email); params.append('from_email', fromEmail);
params.append('recipient', recipient); params.append('recipient', recipient);
params.append('subject', subject); params.append('subject', subject);
params.append('content', content); params.append('content', content);
@@ -58,19 +61,46 @@ serve(async (req: Request): Promise<Response> => {
} }
try { 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(); const body: EmailRequest = await req.json();
// Validate required fields // 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( 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" } } { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
); );
} }
// Basic email validation // Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 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( return new Response(
JSON.stringify({ success: false, message: "Invalid email format" }), JSON.stringify({ success: false, message: "Invalid email format" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } { 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(`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}`); console.log(`Subject: ${body.subject}`);
const result = await sendViaMailketing(body); const result = await sendViaMailketing(body, apiToken, fromName, fromEmail);
return new Response( return new Response(
JSON.stringify(result), JSON.stringify(result),

View File

@@ -1,7 +1,6 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts"; import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
import QRCode from 'https://esm.sh/qrcode@1.5.3';
const corsHeaders = { const corsHeaders = {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@@ -259,44 +258,29 @@ serve(async (req: Request): Promise<Response> => {
); );
} }
// Get platform settings // Get platform settings (includes email configuration)
const { data: settings } = await supabase const { data: platformSettings, error: platformError } = await supabase
.from("platform_settings") .from("platform_settings")
.select("*") .select("*")
.single(); .single();
if (!settings) { if (platformError || !platformSettings) {
console.error('Error fetching platform settings:', platformError);
return new Response( return new Response(
JSON.stringify({ success: false, message: "Platform settings not configured" }), JSON.stringify({ success: false, message: "Platform settings not configured" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
); );
} }
const brandName = platformSettings.brand_name || "ACCESS HUB";
// Build email payload // Build email payload
const allVariables = { const allVariables = {
recipient_name: recipient_name || "Pelanggan", recipient_name: recipient_name || "Pelanggan",
platform_name: settings.brand_name || "Platform", platform_name: brandName,
...variables, ...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 subject = replaceVariables(template.email_subject || template.subject || "", allVariables);
const htmlContent = replaceVariables(template.email_body_html || template.body_html || template.body_text || "", 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({ const htmlBody = EmailTemplateRenderer.render({
subject: subject, subject: subject,
content: htmlContent, content: htmlContent,
brandName: settings.brand_name || "ACCESS HUB", brandName: brandName,
}); });
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to: recipient_email, to: recipient_email,
subject, subject,
html: htmlBody, html: htmlBody,
from_name: settings.brand_email_from_name || settings.brand_name || "Notifikasi", from_name: platformSettings.integration_email_from_name || brandName || "Notifikasi",
from_email: settings.smtp_from_email || "noreply@example.com", from_email: platformSettings.integration_email_from_email || "noreply@example.com",
}; };
// Determine provider and send // 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}`); console.log(`Sending email via ${provider} to ${recipient_email}`);
switch (provider) { switch (provider) {
case "mailketing": 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"); if (!mailketingToken) throw new Error("Mailketing API token not configured");
await sendViaMailketing(emailPayload, mailketingToken); await sendViaMailketing(emailPayload, mailketingToken);
break; 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: default:
throw new Error(`Unknown email provider: ${provider}`); throw new Error(`Unknown email provider: ${provider}. Only 'mailketing' is supported.`);
} }
// Log notification // 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 -- Update order_created email template to remove QR code
-- This migration adds the QR code section to the order confirmation email -- QR code is now displayed on the order detail page instead
UPDATE notification_templates UPDATE notification_templates
SET SET
@@ -12,28 +12,6 @@ SET
<p>Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:</p> <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 --> <!-- Order Summary Section -->
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;"> <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> <h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
@@ -59,6 +37,16 @@ SET
</p> </p>
</div> </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;"> <p style="font-size: 14px; color: #666;">
Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif. Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif.
</p> </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> <tr>
<td align="left" style="font-size: 12px; line-height: 18px; font-family: monospace; color: #555;"> <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 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;">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>
</td> </td>
</tr> </tr>
</table> </table>