From 053465afa3724506c5ad211645ff916cc9a67c0d Mon Sep 17 00:00:00 2001 From: dwindown Date: Sat, 3 Jan 2026 18:02:25 +0700 Subject: [PATCH] Fix email system and implement OTP confirmation flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 2 + .../admin/settings/IntegrasiTab.tsx | 158 +++--- .../admin/settings/NotifikasiTab.tsx | 43 +- src/hooks/useAuth.tsx | 36 +- src/pages/Auth.tsx | 7 +- src/pages/Checkout.tsx | 504 ++++++++++++------ src/pages/ConfirmOTP.tsx | 255 +++++++++ src/pages/admin/AdminMembers.tsx | 162 +++++- supabase/functions/delete-user/index.ts | 61 +++ supabase/functions/handle-order-paid/index.ts | 40 +- supabase/functions/send-auth-otp/index.ts | 201 ++----- .../send-consultation-reminder/index.ts | 190 ------- supabase/functions/send-email-v2/index.ts | 56 +- supabase/functions/send-notification/index.ts | 79 +-- supabase/functions/send-test-email/index.ts | 179 ------- ...0001_update_auth_otp_email_copywriting.sql | 54 ++ ..._update_order_created_template_with_qr.sql | 36 +- ...20250103000002_add_platform_url_column.sql | 15 + ...20250103000002_add_test_email_template.sql | 48 ++ ...fix_email_templates_order_id_and_links.sql | 197 +++++++ supabase/shared/email-template-renderer.ts | 6 +- 21 files changed, 1381 insertions(+), 948 deletions(-) create mode 100644 src/pages/ConfirmOTP.tsx create mode 100644 supabase/functions/delete-user/index.ts delete mode 100644 supabase/functions/send-consultation-reminder/index.ts delete mode 100644 supabase/functions/send-test-email/index.ts create mode 100644 supabase/migrations/20250103000001_update_auth_otp_email_copywriting.sql create mode 100644 supabase/migrations/20250103000002_add_platform_url_column.sql create mode 100644 supabase/migrations/20250103000002_add_test_email_template.sql create mode 100644 supabase/migrations/20250103000003_fix_email_templates_order_id_and_links.sql diff --git a/src/App.tsx b/src/App.tsx index 9a20ba2..cfea967 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { CartProvider } from "@/contexts/CartContext"; import { BrandingProvider } from "@/hooks/useBranding"; import Index from "./pages/Index"; import Auth from "./pages/Auth"; +import ConfirmOTP from "./pages/ConfirmOTP"; import Products from "./pages/Products"; import ProductDetail from "./pages/ProductDetail"; import Checkout from "./pages/Checkout"; @@ -53,6 +54,7 @@ const App = () => ( } /> } /> + } /> } /> } /> } /> diff --git a/src/components/admin/settings/IntegrasiTab.tsx b/src/components/admin/settings/IntegrasiTab.tsx index d8fbd37..d20ecb7 100644 --- a/src/components/admin/settings/IntegrasiTab.tsx +++ b/src/components/admin/settings/IntegrasiTab.tsx @@ -20,14 +20,12 @@ interface IntegrationSettings { google_oauth_config?: string; integration_email_provider: string; integration_email_api_base_url: string; + integration_email_api_token: string; + integration_email_from_name: string; + integration_email_from_email: string; integration_privacy_url: string; integration_terms_url: string; integration_n8n_test_mode: boolean; - // Mailketing specific settings - provider: 'mailketing' | 'smtp'; - api_token: string; - from_name: string; - from_email: string; } const emptySettings: IntegrationSettings = { @@ -37,13 +35,12 @@ const emptySettings: IntegrationSettings = { integration_google_calendar_id: '', integration_email_provider: 'mailketing', integration_email_api_base_url: '', + integration_email_api_token: '', + integration_email_from_name: '', + integration_email_from_email: '', integration_privacy_url: '/privacy', integration_terms_url: '/terms', integration_n8n_test_mode: false, - provider: 'mailketing', - api_token: '', - from_name: '', - from_email: '', }; export function IntegrasiTab() { @@ -64,12 +61,6 @@ export function IntegrasiTab() { .select('*') .single(); - // Fetch email provider settings from notification_settings - const { data: emailData } = await supabase - .from('notification_settings') - .select('*') - .single(); - if (platformData) { setSettings({ id: platformData.id, @@ -80,14 +71,12 @@ export function IntegrasiTab() { google_oauth_config: platformData.google_oauth_config || '', integration_email_provider: platformData.integration_email_provider || 'mailketing', integration_email_api_base_url: platformData.integration_email_api_base_url || '', + integration_email_api_token: platformData.integration_email_api_token || '', + integration_email_from_name: platformData.integration_email_from_name || platformData.brand_email_from_name || '', + integration_email_from_email: platformData.integration_email_from_email || '', integration_privacy_url: platformData.integration_privacy_url || '/privacy', integration_terms_url: platformData.integration_terms_url || '/terms', integration_n8n_test_mode: platformData.integration_n8n_test_mode || false, - // Email settings from notification_settings - provider: emailData?.provider || 'mailketing', - api_token: emailData?.api_token || '', - from_name: emailData?.from_name || platformData.brand_email_from_name || '', - from_email: emailData?.from_email || '', }); } setLoading(false); @@ -97,7 +86,7 @@ export function IntegrasiTab() { setSaving(true); try { - // Save platform settings + // Save platform settings (includes email settings) const platformPayload = { integration_n8n_base_url: settings.integration_n8n_base_url, integration_whatsapp_number: settings.integration_whatsapp_number, @@ -106,6 +95,9 @@ export function IntegrasiTab() { google_oauth_config: settings.google_oauth_config, integration_email_provider: settings.integration_email_provider, integration_email_api_base_url: settings.integration_email_api_base_url, + integration_email_api_token: settings.integration_email_api_token, + integration_email_from_name: settings.integration_email_from_name, + integration_email_from_email: settings.integration_email_from_email, integration_privacy_url: settings.integration_privacy_url, integration_terms_url: settings.integration_terms_url, integration_n8n_test_mode: settings.integration_n8n_test_mode, @@ -136,6 +128,9 @@ export function IntegrasiTab() { integration_google_calendar_id: settings.integration_google_calendar_id, integration_email_provider: settings.integration_email_provider, integration_email_api_base_url: settings.integration_email_api_base_url, + integration_email_api_token: settings.integration_email_api_token, + integration_email_from_name: settings.integration_email_from_name, + integration_email_from_email: settings.integration_email_from_email, integration_privacy_url: settings.integration_privacy_url, integration_terms_url: settings.integration_terms_url, integration_n8n_test_mode: settings.integration_n8n_test_mode, @@ -153,34 +148,6 @@ export function IntegrasiTab() { } } - // Save email provider settings to notification_settings - const emailPayload = { - provider: settings.provider, - api_token: settings.api_token, - from_name: settings.from_name, - from_email: settings.from_email, - }; - - const { data: existingEmailSettings } = await supabase - .from('notification_settings') - .select('id') - .maybeSingle(); - - if (existingEmailSettings?.id) { - const { error: emailError } = await supabase - .from('notification_settings') - .update(emailPayload) - .eq('id', existingEmailSettings.id); - - if (emailError) throw emailError; - } else { - const { error: emailError } = await supabase - .from('notification_settings') - .insert(emailPayload); - - if (emailError) throw emailError; - } - toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' }); } catch (error: any) { toast({ title: 'Error', description: error.message, variant: 'destructive' }); @@ -195,21 +162,50 @@ export function IntegrasiTab() { setSendingTest(true); try { - const { data, error } = await supabase.functions.invoke('send-email-v2', { + // Get brand name for test email + const { data: platformData } = await supabase + .from('platform_settings') + .select('brand_name') + .single(); + + const brandName = platformData?.brand_name || 'ACCESS HUB'; + + // Test email content using proper HTML template + const testEmailContent = ` +
+

Email Test - ${brandName}

+ +

Halo,

+ +

Ini adalah email tes dari sistem ${brandName}.

+ +
+

+ ✓ Konfigurasi email berhasil!
+ Email Anda telah terkirim dengan benar menggunakan provider: Mailketing +

+
+ +

+ Jika Anda menerima email ini, berarti konfigurasi email sudah benar. +

+ +

+ Terima kasih,
+ Tim ${brandName} +

+
+ `; + + const { data, error } = await supabase.functions.invoke('send-notification', { body: { - recipient: testEmail, - api_token: settings.api_token, - from_name: settings.from_name, - from_email: settings.from_email, - subject: 'Test Email dari Access Hub', - content: ` -

Test Email

-

Ini adalah email uji coba dari aplikasi Access Hub Anda.

-

Jika Anda menerima email ini, konfigurasi Mailketing API sudah berfungsi dengan baik!

-

Kirim ke: ${testEmail}

-
-

Best regards,
Access Hub Team

- `, + template_key: 'test_email', + recipient_email: testEmail, + recipient_name: 'Admin', + variables: { + brand_name: brandName, + test_email: testEmail + } }, }); @@ -228,7 +224,7 @@ export function IntegrasiTab() { } }; - const isEmailConfigured = settings.api_token && settings.from_email; + const isEmailConfigured = settings.integration_email_api_token && settings.integration_email_from_email; if (loading) return
; @@ -437,20 +433,19 @@ export function IntegrasiTab() {
- {settings.provider === 'mailketing' && ( + {settings.integration_email_provider === 'mailketing' && ( <>
setSettings({ ...settings, api_token: e.target.value })} + value={settings.integration_email_api_token} + onChange={(e) => setSettings({ ...settings, integration_email_api_token: e.target.value })} placeholder="Masukkan API token dari Mailketing" className="border-2" /> @@ -473,8 +468,8 @@ export function IntegrasiTab() {
setSettings({ ...settings, from_name: e.target.value })} + value={settings.integration_email_from_name} + onChange={(e) => setSettings({ ...settings, integration_email_from_name: e.target.value })} placeholder="Nama Bisnis" className="border-2" /> @@ -483,8 +478,8 @@ export function IntegrasiTab() { setSettings({ ...settings, from_email: e.target.value })} + value={settings.integration_email_from_email} + onChange={(e) => setSettings({ ...settings, integration_email_from_email: e.target.value })} placeholder="info@domain.com" className="border-2" /> @@ -509,21 +504,6 @@ export function IntegrasiTab() {
)} - - {settings.provider === 'smtp' && ( -
- - setSettings({ ...settings, integration_email_api_base_url: e.target.value })} - placeholder="https://api.resend.com" - className="border-2" - /> -

- Konfigurasi SMTP masih di bagian Notifikasi -

-
- )}
diff --git a/src/components/admin/settings/NotifikasiTab.tsx b/src/components/admin/settings/NotifikasiTab.tsx index f8cfdfa..3799ec6 100644 --- a/src/components/admin/settings/NotifikasiTab.tsx +++ b/src/components/admin/settings/NotifikasiTab.tsx @@ -494,37 +494,30 @@ export function NotifikasiTab() { setTestingTemplate(template.id); try { - // Fetch email settings from notification_settings - const { data: emailData } = await supabase - .from('notification_settings') - .select('*') + // Fetch platform settings to get brand name + const { data: platformData } = await supabase + .from('platform_settings') + .select('brand_name') .single(); - if (!emailData || !emailData.api_token || !emailData.from_email) { - throw new Error('Konfigurasi email provider belum lengkap'); - } + const brandName = platformData?.brand_name || 'ACCESS HUB'; - // Import EmailTemplateRenderer and ShortcodeProcessor - const { EmailTemplateRenderer, ShortcodeProcessor } = await import('@/lib/email-templates/master-template'); + // Import ShortcodeProcessor to get dummy data + const { ShortcodeProcessor } = await import('@/lib/email-templates/master-template'); - // Process shortcodes and render with master template - const processedSubject = ShortcodeProcessor.process(template.email_subject || ''); - const processedContent = ShortcodeProcessor.process(template.email_body_html || ''); - const fullHtml = EmailTemplateRenderer.render({ - subject: processedSubject, - content: processedContent, - brandName: 'ACCESS HUB' - }); + // Get default dummy data for all template variables + const dummyData = ShortcodeProcessor.getDummyData(); - // Send test email using send-email-v2 - const { data, error } = await supabase.functions.invoke('send-email-v2', { + // Send test email using send-notification (same as IntegrasiTab) + const { data, error } = await supabase.functions.invoke('send-notification', { body: { - recipient: template.test_email, - api_token: emailData.api_token, - from_name: emailData.from_name, - from_email: emailData.from_email, - subject: processedSubject, - content: fullHtml, + template_key: template.key, + recipient_email: template.test_email, + recipient_name: dummyData.nama, + variables: { + ...dummyData, + platform_name: brandName, + }, }, }); diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 8dbe184..4a005e0 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -107,37 +107,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { const sendAuthOTP = async (userId: string, email: string) => { try { - const { data: { session } } = await supabase.auth.getSession(); - const token = session?.access_token || import.meta.env.VITE_SUPABASE_ANON_KEY; + const { data, error } = await supabase.functions.invoke('send-auth-otp', { + body: { user_id: userId, email } + }); - console.log('Sending OTP request', { userId, email, hasSession: !!session }); - - const response = await fetch( - `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/send-auth-otp`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ user_id: userId, email }), - } - ); - - console.log('OTP response status:', response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error('OTP request failed:', response.status, errorText); + if (error) { + console.error('OTP request error:', error); return { success: false, - message: `HTTP ${response.status}: ${errorText}` + message: error.message || 'Failed to send OTP' }; } - const result = await response.json(); - console.log('OTP result:', result); - return result; + console.log('OTP result:', data); + return { + success: data?.success || false, + message: data?.message || 'OTP sent successfully' + }; } catch (error: any) { console.error('Error sending OTP:', error); return { diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index 9fb2b8b..a4c975e 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useNavigate, Link } from 'react-router-dom'; +import { useNavigate, Link, useLocation } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -25,6 +25,7 @@ export default function Auth() { const [isResendOTP, setIsResendOTP] = useState(false); // Track if this is resend OTP for existing user const { signIn, signUp, user, sendAuthOTP, verifyAuthOTP, getUserByEmail } = useAuth(); const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { if (user) { @@ -100,7 +101,9 @@ export default function Auth() { toast({ title: 'Error', description: error.message, variant: 'destructive' }); setLoading(false); } else { - navigate('/dashboard'); + // Get redirect from URL state or use default + const redirectTo = (location.state as any)?.redirectTo || '/dashboard'; + navigate(redirectTo); setLoading(false); } } else { diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx index 3b8e8f2..69d0412 100644 --- a/src/pages/Checkout.tsx +++ b/src/pages/Checkout.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { AppLayout } from "@/components/AppLayout"; import { useCart } from "@/contexts/CartContext"; @@ -11,7 +11,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Input } from "@/components/ui/input"; import { toast } from "@/hooks/use-toast"; import { formatIDR } from "@/lib/format"; -import { Trash2, CreditCard, Loader2, QrCode } from "lucide-react"; +import { Trash2, CreditCard, Loader2, QrCode, ArrowLeft } from "lucide-react"; +import { Link } from "react-router-dom"; // Edge function base URL - configurable via env with sensible default const getEdgeFunctionBaseUrl = (): string => { @@ -24,7 +25,7 @@ type CheckoutStep = "cart" | "payment"; export default function Checkout() { const { items, removeItem, clearCart, total } = useCart(); - const { user, signIn, signUp } = useAuth(); + const { user, signIn, signUp, sendAuthOTP, verifyAuthOTP } = useAuth(); const navigate = useNavigate(); const [loading, setLoading] = useState(false); @@ -36,6 +37,10 @@ export default function Checkout() { const [authEmail, setAuthEmail] = useState(""); const [authPassword, setAuthPassword] = useState(""); const [authName, setAuthName] = useState(""); + const [showOTP, setShowOTP] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [pendingUserId, setPendingUserId] = useState(null); + const [resendCountdown, setResendCountdown] = useState(0); const checkPaymentStatus = async (oid: string) => { const { data: order } = await supabase.from("orders").select("payment_status").eq("id", oid).single(); @@ -49,7 +54,8 @@ export default function Checkout() { const handleCheckout = async () => { if (!user) { toast({ title: "Login diperlukan", description: "Silakan login untuk melanjutkan pembayaran" }); - navigate("/auth"); + // Pass current location for redirect after login + navigate("/auth", { state: { redirectTo: window.location.pathname } }); return; } @@ -99,6 +105,42 @@ export default function Checkout() { const { error: itemsError } = await supabase.from("order_items").insert(orderItems); if (itemsError) throw new Error("Gagal menambahkan item order"); + // Send order_created email IMMEDIATELY after order is created (before payment QR) + console.log('[CHECKOUT] About to send order_created email for order:', order.id); + console.log('[CHECKOUT] User email:', user.email); + + try { + const result = await supabase.functions.invoke('send-notification', { + body: { + template_key: 'order_created', + recipient_email: user.email, + recipient_name: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan', + variables: { + nama: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan', + email: user.email, + order_id: order.id, + order_id_short: order.id.substring(0, 8), + tanggal_pesanan: new Date().toLocaleDateString('id-ID', { + day: '2-digit', + month: 'short', + year: 'numeric' + }), + total: formatIDR(total), + metode_pembayaran: 'QRIS', + produk: items.map(item => item.title).join(', '), + payment_link: `${window.location.origin}/orders/${order.id}`, + thank_you_page: `${window.location.origin}/orders/${order.id}` + } + } + }); + console.log('[CHECKOUT] send-notification called successfully:', result); + } catch (emailErr) { + console.error('[CHECKOUT] Failed to send order_created email:', emailErr); + // Don't block checkout flow if email fails + } + + console.log('[CHECKOUT] Order creation email call completed'); + // Build description from product titles const productTitles = items.map(item => item.title).join(", "); @@ -116,44 +158,6 @@ export default function Checkout() { throw new Error(paymentError.message || 'Gagal membuat pembayaran'); } - // Send order_created email with QR code (wait for completion before navigating) - console.log('[CHECKOUT] About to send order_created email for order:', order.id); - console.log('[CHECKOUT] User email:', user.email); - console.log('[CHECKOUT] Payment data QR string:', paymentData?.qr_string); - - try { - const result = await supabase.functions.invoke('send-notification', { - body: { - template_key: 'order_created', - recipient_email: user.email, - recipient_name: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan', - variables: { - nama: user.user_metadata.name || user.email?.split('@')[0] || 'Pelanggan', - email: user.email, - order_id: order.id, - tanggal_pesanan: new Date().toLocaleDateString('id-ID', { - day: '2-digit', - month: 'short', - year: 'numeric' - }), - total: formatIDR(total), - metode_pembayaran: 'QRIS', - produk: items.map(item => item.title).join(', '), - payment_link: `${window.location.origin}/orders/${order.id}`, - thank_you_page: `${window.location.origin}/orders/${order.id}`, - qr_string: paymentData?.qr_string || '', - qr_expiry_time: paymentData?.expired_at ? new Date(paymentData.expired_at).toLocaleString('id-ID') : '' - } - } - }); - console.log('[CHECKOUT] send-notification called successfully:', result); - } catch (emailErr) { - console.error('[CHECKOUT] Failed to send order_created email:', emailErr); - // Don't block checkout flow if email fails - } - - console.log('[CHECKOUT] Order creation email call completed'); - // Clear cart and redirect to order detail page to show QR code clearCart(); navigate(`/orders/${order.id}`); @@ -212,25 +216,131 @@ export default function Checkout() { } setAuthLoading(true); - const { error, data } = await signUp(authEmail, authPassword, authName); - if (error) { - toast({ - title: "Registrasi gagal", - description: error.message || "Gagal membuat akun", - variant: "destructive", - }); - setAuthLoading(false); - } else { - toast({ - title: "Registrasi berhasil", - description: "Silakan cek email untuk verifikasi akun Anda", - }); - setAuthModalOpen(false); - setAuthLoading(false); + try { + const { data, error } = await signUp(authEmail, authPassword, authName); + + if (error) { + toast({ + title: "Registrasi gagal", + description: error.message || "Gagal membuat akun", + variant: "destructive", + }); + setAuthLoading(false); + return; + } + + if (!data?.user) { + toast({ title: "Error", description: "Failed to create user account. Please try again.", variant: "destructive" }); + setAuthLoading(false); + return; + } + + // User created, now send OTP + const userId = data.user.id; + const result = await sendAuthOTP(userId, authEmail); + + if (result.success) { + setPendingUserId(userId); + setShowOTP(true); + setResendCountdown(60); + toast({ + title: "OTP Terkirim", + description: "Kode verifikasi telah dikirim ke email Anda. Silakan cek inbox.", + }); + } else { + toast({ title: "Error", description: result.message, variant: "destructive" }); + } + } catch (error: any) { + toast({ title: "Error", description: error.message || "Terjadi kesalahan", variant: "destructive" }); } + + setAuthLoading(false); }; + const handleOTPSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!pendingUserId) { + toast({ title: "Error", description: "Session expired. Please try again.", variant: "destructive" }); + setShowOTP(false); + return; + } + + if (otpCode.length !== 6) { + toast({ title: "Error", description: "OTP harus 6 digit", variant: "destructive" }); + return; + } + + setAuthLoading(true); + + try { + const result = await verifyAuthOTP(pendingUserId, otpCode); + + if (result.success) { + toast({ + title: "Verifikasi Berhasil", + description: "Akun Anda telah terverifikasi. Mengalihkan...", + }); + + // Auto-login after OTP verification + const loginResult = await signIn(authEmail, authPassword); + + if (loginResult.error) { + toast({ + title: "Peringatan", + description: "Akun terverifikasi tapi gagal login otomatis. Silakan login manual.", + variant: "destructive" + }); + } + + setShowOTP(false); + setAuthModalOpen(false); + // Reset form + setAuthName(""); + setAuthEmail(""); + setAuthPassword(""); + setOtpCode(""); + setPendingUserId(null); + } else { + toast({ title: "Error", description: result.message, variant: "destructive" }); + } + } catch (error: any) { + toast({ title: "Error", description: error.message || "Verifikasi gagal", variant: "destructive" }); + } + + setAuthLoading(false); + }; + + const handleResendOTP = async () => { + if (resendCountdown > 0 || !pendingUserId) return; + + setAuthLoading(true); + + try { + const result = await sendAuthOTP(pendingUserId, authEmail); + + if (result.success) { + setResendCountdown(60); + toast({ title: "OTP Terkirim Ulang", description: "Kode verifikasi baru telah dikirim ke email Anda." }); + } else { + toast({ title: "Error", description: result.message, variant: "destructive" }); + } + } catch (error: any) { + toast({ title: "Error", description: error.message || "Gagal mengirim OTP", variant: "destructive" }); + } + + setAuthLoading(false); + }; + + // Resend countdown timer + useEffect(() => { + if (resendCountdown > 0) { + const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000); + return () => clearTimeout(timer); + } + }, [resendCountdown]); + return (
@@ -311,116 +421,190 @@ export default function Checkout() { )} ) : ( - + { + if (!open) { + // Reset state when closing + setShowOTP(false); + setOtpCode(""); + setPendingUserId(null); + setResendCountdown(0); + } + setAuthModalOpen(open); + }}> - + e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}> - Login atau Daftar + {showOTP ? "Verifikasi Email" : "Login atau Daftar"} - - - Login - Daftar - - -
-
- - setAuthEmail(e.target.value)} - required - /> -
-
- - setAuthPassword(e.target.value)} - required - /> -
- -
-
- -
-
- - setAuthName(e.target.value)} - required - /> -
-
- - setAuthEmail(e.target.value)} - required - /> -
-
- - setAuthPassword(e.target.value)} - required - minLength={6} - /> -
- -
-
-
+ + {!showOTP ? ( + + + Login + Daftar + + +
+
+ + setAuthEmail(e.target.value)} + required + /> +
+
+ + setAuthPassword(e.target.value)} + required + /> +
+ +
+
+ +
+
+ + setAuthName(e.target.value)} + required + /> +
+
+ + setAuthEmail(e.target.value)} + required + /> +
+
+ + setAuthPassword(e.target.value)} + required + minLength={6} + /> +
+ +
+
+
+ ) : ( +
+
+

+ Masukkan kode 6 digit yang telah dikirim ke {authEmail} +

+
+
+ + setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + maxLength={6} + className="text-center text-2xl tracking-widest" + required + /> +
+ +
+ + {pendingUserId && authEmail && ( +

+ Modal tertutup tidak sengaja?{" "} + { + e.preventDefault(); + setShowOTP(false); + setAuthModalOpen(false); + window.location.href = `/confirm-otp?user_id=${pendingUserId}&email=${encodeURIComponent(authEmail)}`; + }} + > + Buka halaman verifikasi khusus + +

+ )} +
+
+ )}
)} diff --git a/src/pages/ConfirmOTP.tsx b/src/pages/ConfirmOTP.tsx new file mode 100644 index 0000000..f2deb65 --- /dev/null +++ b/src/pages/ConfirmOTP.tsx @@ -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 ( + +
+ + +

Sesi tidak valid atau telah kedaluwarsa.

+ + + +
+
+
+
+ ); + } + + return ( + +
+
+ {/* Back Button */} + + + + + {/* Card */} + + +
+ +
+ Konfirmasi Email +

+ Masukkan kode 6 digit yang telah dikirim ke {email} +

+
+ +
+
+ + setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + maxLength={6} + className="text-center text-2xl tracking-widest" + required + autoFocus + /> +
+ + + +
+ +
+ +
+

+

💡 Tips: Kode berlaku selama 15 menit.

+

Cek folder spam jika email tidak muncul di inbox.

+

+
+
+
+
+ + {/* Help Box */} + + +
+

Tidak menerima email?

+
    +
  • Pastikan email yang dimasukkan benar
  • +
  • Cek folder spam/junk email
  • +
  • Tunggu beberapa saat, email mungkin memerlukan waktu untuk sampai
  • +
+ {email && ( +

+ Belum mendaftar?{" "} + + Kembali ke pendaftaran + +

+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/pages/admin/AdminMembers.tsx b/src/pages/admin/AdminMembers.tsx index b95e7ba..c398ac5 100644 --- a/src/pages/admin/AdminMembers.tsx +++ b/src/pages/admin/AdminMembers.tsx @@ -11,8 +11,18 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { Skeleton } from "@/components/ui/skeleton"; import { Input } from "@/components/ui/input"; import { formatDateTime } from "@/lib/format"; -import { Eye, Shield, ShieldOff, Search, X } from "lucide-react"; +import { Eye, Shield, ShieldOff, Search, X, Trash2 } from "lucide-react"; import { toast } from "@/hooks/use-toast"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; interface Member { id: string; @@ -39,6 +49,9 @@ export default function AdminMembers() { const [dialogOpen, setDialogOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [filterRole, setFilterRole] = useState('all'); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [memberToDelete, setMemberToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); useEffect(() => { if (!authLoading) { @@ -107,6 +120,83 @@ export default function AdminMembers() { } }; + const confirmDeleteMember = (member: Member) => { + if (member.id === user?.id) { + toast({ title: "Error", description: "Tidak bisa menghapus akun sendiri", variant: "destructive" }); + return; + } + setMemberToDelete(member); + setDeleteDialogOpen(true); + }; + + const deleteMember = async () => { + if (!memberToDelete) return; + + setIsDeleting(true); + try { + const userId = memberToDelete.id; + + // Step 1: Delete auth_otps + await supabase.from("auth_otps").delete().eq("user_id", userId); + + // Step 2: Delete order_items (first to avoid FK issues) + const { data: orders } = await supabase.from("orders").select("id").eq("user_id", userId); + if (orders && orders.length > 0) { + const orderIds = orders.map(o => o.id); + await supabase.from("order_items").delete().in("order_id", orderIds); + } + + // Step 3: Delete orders + await supabase.from("orders").delete().eq("user_id", userId); + + // Step 4: Delete user_access + await supabase.from("user_access").delete().eq("user_id", userId); + + // Step 5: Delete video_progress + await supabase.from("video_progress").delete().eq("user_id", userId); + + // Step 6: Delete consulting_slots + await supabase.from("consulting_slots").delete().eq("user_id", userId); + + // Step 7: Delete calendar_events + await supabase.from("calendar_events").delete().eq("user_id", userId); + + // Step 8: Delete user_roles + await supabase.from("user_roles").delete().eq("user_id", userId); + + // Step 9: Delete profile + await supabase.from("profiles").delete().eq("id", userId); + + // Step 10: Delete from auth.users using edge function + const { error: deleteError } = await supabase.functions.invoke('delete-user', { + body: { user_id: userId } + }); + + if (deleteError) { + console.error('Error deleting from auth.users:', deleteError); + throw new Error(`Gagal menghapus user dari auth: ${deleteError.message}`); + } + + toast({ + title: "Berhasil", + description: `Member ${memberToDelete.email || memberToDelete.name} berhasil dihapus beserta semua data terkait` + }); + + setDeleteDialogOpen(false); + setMemberToDelete(null); + fetchMembers(); + } catch (error: any) { + console.error('Delete member error:', error); + toast({ + title: "Error", + description: error.message || "Gagal menghapus member", + variant: "destructive" + }); + } finally { + setIsDeleting(false); + } + }; + if (authLoading || loading) { return ( @@ -243,6 +333,15 @@ export default function AdminMembers() { > {adminIds.has(member.id) ? : } + ))} @@ -289,6 +388,16 @@ export default function AdminMembers() { {adminIds.has(member.id) ? : } {adminIds.has(member.id) ? "Hapus Admin" : "Jadikan Admin"} +
@@ -334,6 +443,57 @@ export default function AdminMembers() { )} + + + + + Hapus Member? + +
+

+ Anda akan menghapus member {memberToDelete?.email || memberToDelete?.name}. +

+

+ Tindakan ini akan menghapus SEMUA data terkait member ini: +

+
    +
  • Order dan item order
  • +
  • Akses produk
  • +
  • Progress video
  • +
  • Jadwal konsultasi
  • +
  • Event kalender
  • +
  • Role admin (jika ada)
  • +
  • Profil user
  • +
  • Akun autentikasi
  • +
+

+ Tindakan ini TIDAK BISA dibatalkan. +

+
+
+
+ + Batal + + {isDeleting ? ( + <> + + Menghapus... + + ) : ( + <> + + Ya, Hapus Member + + )} + + +
+
); diff --git a/supabase/functions/delete-user/index.ts b/supabase/functions/delete-user/index.ts new file mode 100644 index 0000000..e3cf0ab --- /dev/null +++ b/supabase/functions/delete-user/index.ts @@ -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" } } + ); + } +}); diff --git a/supabase/functions/handle-order-paid/index.ts b/supabase/functions/handle-order-paid/index.ts index fbd6bb6..f58677f 100644 --- a/supabase/functions/handle-order-paid/index.ts +++ b/supabase/functions/handle-order-paid/index.ts @@ -309,18 +309,30 @@ async function sendNotification( return; } - // Send email via Mailketing - await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email-v2`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`, - }, - body: JSON.stringify({ - to: data.email, - subject: template.email_subject, - html: template.email_body_html, - shortcodeData: data, - }), - }); + // Send email via send-notification (which will process shortcodes and call send-email-v2) + try { + const notificationResponse = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-notification`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`, + }, + body: JSON.stringify({ + template_key: templateKey, + recipient_email: data.email, + recipient_name: data.user_name || data.nama, + variables: data, + }), + }); + + if (!notificationResponse.ok) { + const errorText = await notificationResponse.text(); + console.error("[HANDLE-PAID] Notification send failed:", notificationResponse.status, errorText); + } else { + const result = await notificationResponse.json(); + console.log("[HANDLE-PAID] Notification sent successfully for template:", templateKey, result); + } + } catch (error) { + console.error("[HANDLE-PAID] Exception sending notification:", error); + } } diff --git a/supabase/functions/send-auth-otp/index.ts b/supabase/functions/send-auth-otp/index.ts index ff921d9..4f6b167 100644 --- a/supabase/functions/send-auth-otp/index.ts +++ b/supabase/functions/send-auth-otp/index.ts @@ -1,6 +1,5 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; -import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -12,11 +11,6 @@ interface SendOTPRequest { email: string; } -// Generate 6-digit OTP code -function generateOTP(): string { - return Math.floor(100000 + Math.random() * 900000).toString(); -} - serve(async (req: Request) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); @@ -33,191 +27,88 @@ serve(async (req: Request) => { ); } - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return new Response( - JSON.stringify({ success: false, message: "Invalid email format" }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } - // Initialize Supabase client with service role const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey, { - auth: { - autoRefreshToken: false, - persistSession: false - } - }); + const supabase = createClient(supabaseUrl, supabaseServiceKey); - // Generate OTP code - const otpCode = generateOTP(); - const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now + // Fetch platform settings for brand name and URL + const { data: platformSettings } = await supabase + .from('platform_settings') + .select('brand_name, platform_url') + .single(); - console.log(`Generating OTP for user ${user_id}, email ${email}`); + const platformName = platformSettings?.brand_name || 'ACCESS HUB'; + const platformUrl = platformSettings?.platform_url || 'https://access-hub.com'; + + console.log(`Generating OTP for user ${user_id}`); + + // Generate 6-digit OTP code + const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); + + // Calculate expiration time (15 minutes from now) + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); // Store OTP in database - const { error: otpError } = await supabase + const { error: insertError } = await supabase .from('auth_otps') .insert({ - user_id, - email, + user_id: user_id, + email: email, otp_code: otpCode, - expires_at: expiresAt.toISOString(), + expires_at: expiresAt, }); - if (otpError) { - console.error('Error storing OTP:', otpError); - throw new Error(`Failed to store OTP: ${otpError.message}`); + if (insertError) { + console.error('Error storing OTP:', insertError); + throw new Error(`Failed to store OTP: ${insertError.message}`); } - // Get notification settings - const { data: settings, error: settingsError } = await supabase - .from('notification_settings') - .select('*') - .single(); + console.log(`OTP generated and stored: ${otpCode}, expires at: ${expiresAt}`); - if (settingsError || !settings) { - console.error('Error fetching notification settings:', settingsError); - throw new Error('Notification settings not configured'); - } - - // Get platform settings for brand_name - const { data: platformSettings, error: platformError } = await supabase - .from('platform_settings') - .select('brand_name') - .single(); - - if (platformError) { - console.error('Error fetching platform settings:', platformError); - // Continue with fallback if platform settings not found - } - - const brandName = platformSettings?.brand_name || settings.platform_name || 'ACCESS HUB'; - - // Get email template - console.log('Fetching email template with key: auth_email_verification'); - - const { data: template, error: templateError } = await supabase - .from('notification_templates') - .select('*') - .eq('key', 'auth_email_verification') - .single(); - - console.log('Template query result:', { template, templateError }); - - if (templateError || !template) { - console.error('Error fetching email template:', templateError); - throw new Error('Email template not found. Please create template with key: auth_email_verification'); - } - - // Get user data from auth.users - const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(user_id); - - if (userError || !user) { - console.error('Error fetching user:', userError); - throw new Error('User not found'); - } - - // Prepare template variables - const templateVars = { - platform_name: brandName, - nama: user.user_metadata?.name || user.email || 'Pengguna', - email: email, - otp_code: otpCode, - expiry_minutes: '15', - confirmation_link: '', // Not used for OTP - year: new Date().getFullYear().toString(), - }; - - // Process shortcodes in subject - let subject = template.email_subject; - Object.entries(templateVars).forEach(([key, value]) => { - subject = subject.replace(new RegExp(`{${key}}`, 'g'), value); - }); - - // Process shortcodes in HTML body content - let htmlContent = template.email_body_html; - Object.entries(templateVars).forEach(([key, value]) => { - htmlContent = htmlContent.replace(new RegExp(`{${key}}`, 'g'), value); - }); - - // Wrap in master template - const htmlBody = EmailTemplateRenderer.render({ - subject: subject, - content: htmlContent, - brandName: brandName, - }); - - // Send email via send-email-v2 - console.log(`Sending OTP email to ${email}`); - console.log('Settings:', { - hasMailketingToken: !!settings.mailketing_api_token, - hasApiToken: !!settings.api_token, - hasFromName: !!settings.from_name, - hasFromEmail: !!settings.from_email, - platformName: settings.platform_name, - }); - - // Use api_token (not mailketing_api_token) - const apiToken = settings.api_token || settings.mailketing_api_token; - - if (!apiToken) { - throw new Error('API token not found in notification_settings'); - } - - // Log email details (truncate HTML body for readability) - console.log('Email payload:', { - recipient: email, - from_name: settings.from_name || brandName, - from_email: settings.from_email || 'noreply@example.com', - subject: subject, - content_length: htmlBody.length, - content_preview: htmlBody.substring(0, 200), - }); - - const emailResponse = await fetch(`${supabaseUrl}/functions/v1/send-email-v2`, { + // Send OTP email using send-notification + const notificationUrl = `${supabaseUrl}/functions/v1/send-notification`; + const notificationResponse = await fetch(notificationUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${supabaseServiceKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ - recipient: email, - api_token: apiToken, - from_name: settings.from_name || brandName, - from_email: settings.from_email || 'noreply@example.com', - subject: subject, - content: htmlBody, + template_key: 'auth_email_verification', + recipient_email: email, + recipient_name: email.split('@')[0], + variables: { + nama: email.split('@')[0], + otp_code: otpCode, + email: email, + user_id: user_id, + expiry_minutes: '15', + platform_name: platformName, + platform_url: platformUrl + } }), }); - if (!emailResponse.ok) { - const errorText = await emailResponse.text(); - console.error('Email send error:', emailResponse.status, errorText); - throw new Error(`Failed to send email: ${emailResponse.status} ${errorText}`); + if (!notificationResponse.ok) { + const errorText = await notificationResponse.text(); + console.error('Error sending notification email:', notificationResponse.status, errorText); + throw new Error(`Failed to send OTP email: ${notificationResponse.status} ${errorText}`); } - const emailResult = await emailResponse.json(); - console.log('Email sent successfully:', emailResult); - - // Note: notification_logs table doesn't exist, skipping logging + const notificationResult = await notificationResponse.json(); + console.log('Notification sent successfully:', notificationResult); return new Response( JSON.stringify({ success: true, - message: 'OTP sent successfully' + message: "OTP sent successfully" }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } catch (error: any) { console.error("Error sending OTP:", error); - - // Note: notification_logs table doesn't exist, skipping error logging - return new Response( JSON.stringify({ success: false, diff --git a/supabase/functions/send-consultation-reminder/index.ts b/supabase/functions/send-consultation-reminder/index.ts deleted file mode 100644 index 795eb9e..0000000 --- a/supabase/functions/send-consultation-reminder/index.ts +++ /dev/null @@ -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 => { - 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" } } - ); - } -}); diff --git a/supabase/functions/send-email-v2/index.ts b/supabase/functions/send-email-v2/index.ts index 9f73173..542ca31 100644 --- a/supabase/functions/send-email-v2/index.ts +++ b/supabase/functions/send-email-v2/index.ts @@ -1,4 +1,5 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -7,22 +8,24 @@ const corsHeaders = { interface EmailRequest { recipient: string; - api_token: string; - from_name: string; - from_email: string; subject: string; content: string; } // Send via Mailketing API -async function sendViaMailketing(request: EmailRequest): Promise<{ success: boolean; message: string }> { - const { recipient, api_token, from_name, from_email, subject, content } = request; +async function sendViaMailketing( + request: EmailRequest, + apiToken: string, + fromName: string, + fromEmail: string +): Promise<{ success: boolean; message: string }> { + const { recipient, subject, content } = request; // Build form-encoded body (http_build_query format) const params = new URLSearchParams(); - params.append('api_token', api_token); - params.append('from_name', from_name); - params.append('from_email', from_email); + params.append('api_token', apiToken); + params.append('from_name', fromName); + params.append('from_email', fromEmail); params.append('recipient', recipient); params.append('subject', subject); params.append('content', content); @@ -58,19 +61,46 @@ serve(async (req: Request): Promise => { } try { + // Initialize Supabase client + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + // Fetch email settings from platform_settings + const { data: settings, error: settingsError } = await supabase + .from('platform_settings') + .select('*') + .single(); + + if (settingsError || !settings) { + console.error('Error fetching platform settings:', settingsError); + throw new Error('Failed to fetch email configuration from platform_settings'); + } + + const apiToken = settings.integration_email_api_token; + const fromName = settings.integration_email_from_name || settings.brand_name; + const fromEmail = settings.integration_email_from_email; + + if (!apiToken || !fromEmail) { + return new Response( + JSON.stringify({ success: false, message: "Email not configured. Please set API token and from email in platform settings." }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + const body: EmailRequest = await req.json(); // Validate required fields - if (!body.recipient || !body.api_token || !body.from_name || !body.from_email || !body.subject || !body.content) { + if (!body.recipient || !body.subject || !body.content) { return new Response( - JSON.stringify({ success: false, message: "Missing required fields: recipient, api_token, from_name, from_email, subject, content" }), + JSON.stringify({ success: false, message: "Missing required fields: recipient, subject, content" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } // Basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(body.recipient) || !emailRegex.test(body.from_email)) { + if (!emailRegex.test(body.recipient) || !emailRegex.test(fromEmail)) { return new Response( JSON.stringify({ success: false, message: "Invalid email format" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } @@ -78,10 +108,10 @@ serve(async (req: Request): Promise => { } console.log(`Attempting to send email to: ${body.recipient}`); - console.log(`From: ${body.from_name} <${body.from_email}>`); + console.log(`From: ${fromName} <${fromEmail}>`); console.log(`Subject: ${body.subject}`); - const result = await sendViaMailketing(body); + const result = await sendViaMailketing(body, apiToken, fromName, fromEmail); return new Response( JSON.stringify(result), diff --git a/supabase/functions/send-notification/index.ts b/supabase/functions/send-notification/index.ts index 4fa549c..30730e0 100644 --- a/supabase/functions/send-notification/index.ts +++ b/supabase/functions/send-notification/index.ts @@ -1,7 +1,6 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts"; -import QRCode from 'https://esm.sh/qrcode@1.5.3'; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -259,44 +258,29 @@ serve(async (req: Request): Promise => { ); } - // Get platform settings - const { data: settings } = await supabase + // Get platform settings (includes email configuration) + const { data: platformSettings, error: platformError } = await supabase .from("platform_settings") .select("*") .single(); - if (!settings) { + if (platformError || !platformSettings) { + console.error('Error fetching platform settings:', platformError); return new Response( JSON.stringify({ success: false, message: "Platform settings not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } + const brandName = platformSettings.brand_name || "ACCESS HUB"; + // Build email payload const allVariables = { recipient_name: recipient_name || "Pelanggan", - platform_name: settings.brand_name || "Platform", + platform_name: brandName, ...variables, }; - // Special handling for order_created: generate QR code image - if (template_key === 'order_created' && allVariables.qr_string) { - console.log('[SEND-NOTIFICATION] Generating QR code for order_created email'); - try { - const qrDataUrl = await QRCode.toDataURL(allVariables.qr_string, { - width: 300, - margin: 2, - color: { dark: '#000000', light: '#FFFFFF' } - }); - allVariables.qr_code_image = qrDataUrl; - console.log('[SEND-NOTIFICATION] QR code generated successfully'); - } catch (qrError) { - console.error('[SEND-NOTIFICATION] Failed to generate QR code:', qrError); - // Continue without QR code - don't fail the email - allVariables.qr_code_image = ''; - } - } - const subject = replaceVariables(template.email_subject || template.subject || "", allVariables); const htmlContent = replaceVariables(template.email_body_html || template.body_html || template.body_text || "", allVariables); @@ -304,67 +288,30 @@ serve(async (req: Request): Promise => { const htmlBody = EmailTemplateRenderer.render({ subject: subject, content: htmlContent, - brandName: settings.brand_name || "ACCESS HUB", + brandName: brandName, }); const emailPayload: EmailPayload = { to: recipient_email, subject, html: htmlBody, - from_name: settings.brand_email_from_name || settings.brand_name || "Notifikasi", - from_email: settings.smtp_from_email || "noreply@example.com", + from_name: platformSettings.integration_email_from_name || brandName || "Notifikasi", + from_email: platformSettings.integration_email_from_email || "noreply@example.com", }; // Determine provider and send - const provider = settings.integration_email_provider || "mailketing"; + const provider = platformSettings.integration_email_provider || "mailketing"; console.log(`Sending email via ${provider} to ${recipient_email}`); switch (provider) { case "mailketing": - const mailketingToken = settings.mailketing_api_token || settings.api_token; + const mailketingToken = platformSettings.integration_email_api_token; if (!mailketingToken) throw new Error("Mailketing API token not configured"); await sendViaMailketing(emailPayload, mailketingToken); break; - case "smtp": - await sendViaSMTP(emailPayload, { - host: settings.smtp_host, - port: settings.smtp_port || 587, - username: settings.smtp_username, - password: settings.smtp_password, - from_name: emailPayload.from_name, - from_email: emailPayload.from_email, - use_tls: settings.smtp_use_tls ?? true, - }); - break; - - case "resend": - const resendKey = Deno.env.get("RESEND_API_KEY"); - if (!resendKey) throw new Error("RESEND_API_KEY not configured"); - await sendViaResend(emailPayload, resendKey); - break; - - case "elasticemail": - const elasticKey = Deno.env.get("ELASTICEMAIL_API_KEY"); - if (!elasticKey) throw new Error("ELASTICEMAIL_API_KEY not configured"); - await sendViaElasticEmail(emailPayload, elasticKey); - break; - - case "sendgrid": - const sendgridKey = Deno.env.get("SENDGRID_API_KEY"); - if (!sendgridKey) throw new Error("SENDGRID_API_KEY not configured"); - await sendViaSendGrid(emailPayload, sendgridKey); - break; - - case "mailgun": - const mailgunKey = Deno.env.get("MAILGUN_API_KEY"); - const mailgunDomain = Deno.env.get("MAILGUN_DOMAIN"); - if (!mailgunKey || !mailgunDomain) throw new Error("MAILGUN credentials not configured"); - await sendViaMailgun(emailPayload, mailgunKey, mailgunDomain); - break; - default: - throw new Error(`Unknown email provider: ${provider}`); + throw new Error(`Unknown email provider: ${provider}. Only 'mailketing' is supported.`); } // Log notification diff --git a/supabase/functions/send-test-email/index.ts b/supabase/functions/send-test-email/index.ts deleted file mode 100644 index 0749d37..0000000 --- a/supabase/functions/send-test-email/index.ts +++ /dev/null @@ -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`, - ``, - ` - - - -
-

Email Uji Coba Berhasil! ✓

-

Ini adalah email uji coba dari sistem notifikasi Anda.

-

Jika Anda menerima email ini, konfigurasi SMTP Anda sudah benar.

-
-

- Dikirim dari: ${smtp_from_email}
- Server: ${smtp_host}:${smtp_port} -

-
- -`, - `--${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 { - 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 { - 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 => { - // 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" } } - ); - } -}); diff --git a/supabase/migrations/20250103000001_update_auth_otp_email_copywriting.sql b/supabase/migrations/20250103000001_update_auth_otp_email_copywriting.sql new file mode 100644 index 0000000..72abdc1 --- /dev/null +++ b/supabase/migrations/20250103000001_update_auth_otp_email_copywriting.sql @@ -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 = '--- +

🔐 Konfirmasi Alamat Email

+ +

Selamat datang di {platform_name}!

+ +

Terima kasih telah mendaftar. Untuk mengaktifkan akun Anda, masukkan kode verifikasi 6 digit berikut:

+ +
{otp_code}
+ +

⏰ Berlaku selama {expiry_minutes} menit

+ +

🎯 Cara Verifikasi:

+
    +
  1. Kembali ke halaman pendaftaran - Form OTP sudah otomatis muncul
  2. +
  3. Masukkan kode 6 digit di atas pada kolom verifikasi
  4. +
  5. Klik "Verifikasi Email" dan akun Anda siap digunakan!
  6. +
+ +

🔄 Halaman Khusus Verifikasi

+

Jika Anda kehilangan halaman pendaftaran atau tertutup tidak sengaja, jangan khawatir! Anda tetap bisa memverifikasi akun melalui:

+ +

+ + 📧 Buka Halaman Verifikasi Khusus + +

+ +

+ Link ini akan membawa Anda ke halaman khusus untuk memasukkan kode verifikasi. +

+ +
+

💡 Tips: Cek folder Spam atau Promotions jika email tidak muncul di inbox dalam 1-2 menit.

+
+ +
+ ℹ️ Info: Jika Anda tidak merasa mendaftar di {platform_name}, abaikan email ini dengan aman. +
+---' +WHERE key = 'auth_email_verification'; + +-- Return success message +DO $$ +BEGIN + RAISE NOTICE 'Auth email template updated with improved copywriting and dedicated page link'; +END $$; diff --git a/supabase/migrations/20250103000001_update_order_created_template_with_qr.sql b/supabase/migrations/20250103000001_update_order_created_template_with_qr.sql index 393e626..5faab63 100644 --- a/supabase/migrations/20250103000001_update_order_created_template_with_qr.sql +++ b/supabase/migrations/20250103000001_update_order_created_template_with_qr.sql @@ -1,5 +1,5 @@ --- Update order_created email template to include QR code --- This migration adds the QR code section to the order confirmation email +-- Update order_created email template to remove QR code +-- QR code is now displayed on the order detail page instead UPDATE notification_templates SET @@ -12,28 +12,6 @@ SET

Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:

- -
-

Scan QR untuk Pembayaran

- - - QRIS Payment QR Code - -

- Scan dengan aplikasi e-wallet atau mobile banking Anda -

- -

- Berlaku hingga: {qr_expiry_time} -

- - -
-

Detail Pesanan

@@ -59,6 +37,16 @@ SET

+
+

+ Silakan selesaikan pembayaran Anda dengan mengklik tombol di bawah: +

+ + + Bayar Sekarang + +
+

Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif.

diff --git a/supabase/migrations/20250103000002_add_platform_url_column.sql b/supabase/migrations/20250103000002_add_platform_url_column.sql new file mode 100644 index 0000000..8d86f8b --- /dev/null +++ b/supabase/migrations/20250103000002_add_platform_url_column.sql @@ -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)'; diff --git a/supabase/migrations/20250103000002_add_test_email_template.sql b/supabase/migrations/20250103000002_add_test_email_template.sql new file mode 100644 index 0000000..bfe70ec --- /dev/null +++ b/supabase/migrations/20250103000002_add_test_email_template.sql @@ -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}', + ' +
+

Email Test - {platform_name}

+ +

Halo,

+ +

Ini adalah email tes dari sistem {platform_name}.

+ +
+

+ ✓ Konfigurasi email berhasil!
+ Email Anda telah terkirim dengan benar menggunakan provider: Mailketing +

+
+ +

+ Jika Anda menerima email ini, berarti konfigurasi email sudah benar. +

+ +

+ Terima kasih,
+ Tim {platform_name} +

+
+ ', + 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'; diff --git a/supabase/migrations/20250103000003_fix_email_templates_order_id_and_links.sql b/supabase/migrations/20250103000003_fix_email_templates_order_id_and_links.sql new file mode 100644 index 0000000..92f2346 --- /dev/null +++ b/supabase/migrations/20250103000003_fix_email_templates_order_id_and_links.sql @@ -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 = '--- +
+

Konfirmasi Pesanan

+ +

Halo {nama},

+ +

Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:

+ + +
+

Detail Pesanan

+ +

+ Order ID: #{order_id_short} +

+ +

+ Tanggal: {tanggal_pesanan} +

+ +

+ Produk: {produk} +

+ +

+ Metode Pembayaran: {metode_pembayaran} +

+ +

+ Total: {total} +

+
+ +
+

+ Silakan selesaikan pembayaran Anda dengan mengklik tombol di bawah: +

+ + + Bayar Sekarang + +
+ +

+ Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif. +

+ +

+ Jika Anda memiliki pertanyaan, jangan ragu untuk menghubungi kami. +

+ +

+ Terima kasih,
+ Tim {platform_name} +

+
+---', + 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}', + '--- +
+

Pembayaran Berhasil! ✓

+ +

Halo {nama},

+ +

Terima kasih! Pembayaran Anda telah berhasil dikonfirmasi.

+ +
+

Detail Pesanan

+ +

+ Order ID: #{order_id_short} +

+ +

+ Tanggal: {tanggal_pesanan} +

+ +

+ Produk: {produk} +

+ +

+ Metode Pembayaran: {metode_pembayaran} +

+ +

+ Total: {total} +

+
+ +
+

+ Akses ke produk Anda sudah aktif! Klik tombol di bawah untuk mulai belajar: +

+ + + Akses Sekarang + +
+ +

+ Jika Anda mengalami masalah saat mengakses produk, jangan ragu untuk menghubungi kami. +

+ +

+ Selamat belajar!
+ Tim {platform_name} +

+
+---', + 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}', + '--- +
+

Akses Produk Aktif! 🎉

+ +

Halo {nama},

+ +

Selamat! Akses ke produk Anda telah diaktifkan.

+ +
+

Produk Anda:

+ +

+ {produk} +

+
+ +
+

+ Mulai belajar sekarang dengan mengklik tombol di bawah: +

+ + + Akses Sekarang + +
+ +

+ Nikmati pembelajaran Anda! Jika ada pertanyaan, jangan ragu untuk menghubungi kami. +

+ +

+ Happy learning!
+ Tim {platform_name} +

+
+---', + 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; diff --git a/supabase/shared/email-template-renderer.ts b/supabase/shared/email-template-renderer.ts index 332e8d4..c07128a 100644 --- a/supabase/shared/email-template-renderer.ts +++ b/supabase/shared/email-template-renderer.ts @@ -218,11 +218,7 @@ export class EmailTemplateRenderer {

{{brandName}}

-

Email ini dikirim otomatis. Jangan membalas email ini.

-

- Ubah Preferensi  |  - Unsubscribe -

+

Email ini dikirim otomatis. Jangan membalas email ini.