Fix email system and implement OTP confirmation flow
Email System Fixes: - Fix email sending after payment: handle-order-paid now calls send-notification instead of send-email-v2 directly, properly processing template variables - Fix order_created email timing: sent immediately after order creation, before payment QR code generation - Update email templates to use short order ID (8 chars) instead of full UUID - Add working "Akses Sekarang" buttons to payment_success and access_granted emails - Add platform_url column to platform_settings for email links OTP Verification Flow: - Create dedicated /confirm-otp page for users who close registration modal - Add link in checkout modal and email to dedicated OTP page - Update OTP email template with better copywriting and dedicated page link - Fix send-auth-otp to fetch platform settings for dynamic brand_name and platform_url - Auto-login users after OTP verification in checkout flow Admin Features: - Add delete user functionality with cascade deletion of all related data - Update IntegrasiTab to read/write email settings from platform_settings only - Add test email template for email configuration testing Cleanup: - Remove obsolete send-consultation-reminder and send-test-email functions - Update send-email-v2 to read email config from platform_settings - Remove footer links (Ubah Preferensi/Unsubscribe) from email templates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { CartProvider } from "@/contexts/CartContext";
|
|||||||
import { BrandingProvider } from "@/hooks/useBranding";
|
import { 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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
255
src/pages/ConfirmOTP.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { AppLayout } from "@/components/AppLayout";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { Loader2, ArrowLeft, Mail } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ConfirmOTP() {
|
||||||
|
const { user, signIn, sendAuthOTP, verifyAuthOTP } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [resendCountdown, setResendCountdown] = useState(0);
|
||||||
|
|
||||||
|
// Get user_id and email from URL params or from user state
|
||||||
|
const userId = searchParams.get('user_id') || user?.id;
|
||||||
|
const email = searchParams.get('email') || user?.email;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId && !user) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Sesi tidak valid. Silakan mendaftar ulang.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
navigate('/auth');
|
||||||
|
}
|
||||||
|
}, [userId, user]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
toast({ title: "Error", description: "Session expired. Please try again.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otpCode.length !== 6) {
|
||||||
|
toast({ title: "Error", description: "OTP harus 6 digit", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifyAuthOTP(userId, otpCode);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: "Verifikasi Berhasil",
|
||||||
|
description: "Akun Anda telah terverifikasi. Mengalihkan...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// If user is already logged in, just redirect
|
||||||
|
if (user) {
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/dashboard');
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get email from URL params or use a default
|
||||||
|
const userEmail = email || searchParams.get('email');
|
||||||
|
|
||||||
|
if (userEmail) {
|
||||||
|
// Auto-login after OTP verification
|
||||||
|
// We need the password, which should have been stored or we need to ask user
|
||||||
|
// For now, redirect to login with success message
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/auth', {
|
||||||
|
state: {
|
||||||
|
message: "Email berhasil diverifikasi. Silakan login dengan email dan password Anda.",
|
||||||
|
email: userEmail
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/auth', {
|
||||||
|
state: {
|
||||||
|
message: "Email berhasil diverifikasi. Silakan login."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: "Error", description: error.message || "Verifikasi gagal", variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendOTP = async () => {
|
||||||
|
if (resendCountdown > 0 || !userId || !email) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendAuthOTP(userId, email);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setResendCountdown(60);
|
||||||
|
toast({ title: "OTP Terkirim Ulang", description: "Kode verifikasi baru telah dikirim ke email Anda." });
|
||||||
|
} else {
|
||||||
|
toast({ title: "Error", description: result.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: "Error", description: error.message || "Gagal mengirim OTP", variant: "destructive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resend countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCountdown > 0) {
|
||||||
|
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [resendCountdown]);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Card className="max-w-md mx-auto border-2 border-border">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">Sesi tidak valid atau telah kedaluwarsa.</p>
|
||||||
|
<Link to="/auth">
|
||||||
|
<Button variant="outline" className="mt-4 border-2">
|
||||||
|
Kembali ke Halaman Auth
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-md mx-auto space-y-4">
|
||||||
|
{/* Back Button */}
|
||||||
|
<Link to="/auth">
|
||||||
|
<Button variant="ghost" className="gap-2">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Kembali ke Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<Card className="border-2 border-border shadow-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
|
<Mail className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Konfirmasi Email</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Masukkan kode 6 digit yang telah dikirim ke <strong>{email}</strong>
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="otp-code" className="text-sm font-medium">
|
||||||
|
Kode Verifikasi
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="otp-code"
|
||||||
|
type="text"
|
||||||
|
placeholder="123456"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-2xl tracking-widest"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading || otpCode.length !== 6}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Memverifikasi...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verifikasi Email"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendOTP}
|
||||||
|
disabled={resendCountdown > 0 || loading}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resendCountdown > 0
|
||||||
|
? `Kirim ulang dalam ${resendCountdown} detik`
|
||||||
|
: "Belum menerima kode? Kirim ulang"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<p className="text-xs text-center text-muted-foreground space-y-1">
|
||||||
|
<p>💡 <strong>Tips:</strong> Kode berlaku selama 15 menit.</p>
|
||||||
|
<p>Cek folder spam jika email tidak muncul di inbox.</p>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Help Box */}
|
||||||
|
<Card className="border-2 border-border bg-muted/50">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
<p className="font-medium">Tidak menerima email?</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||||
|
<li>Pastikan email yang dimasukkan benar</li>
|
||||||
|
<li>Cek folder spam/junk email</li>
|
||||||
|
<li>Tunggu beberapa saat, email mungkin memerlukan waktu untuk sampai</li>
|
||||||
|
</ul>
|
||||||
|
{email && (
|
||||||
|
<p className="mt-2">
|
||||||
|
Belum mendaftar?{" "}
|
||||||
|
<Link to="/auth" className="text-primary hover:underline font-medium">
|
||||||
|
Kembali ke pendaftaran
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,8 +11,18 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
61
supabase/functions/delete-user/index.ts
Normal file
61
supabase/functions/delete-user/index.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DeleteUserRequest {
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
serve(async (req: Request) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: DeleteUserRequest = await req.json();
|
||||||
|
const { user_id } = body;
|
||||||
|
|
||||||
|
if (!user_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, message: "user_id is required" }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Deleting user from auth.users: ${user_id}`);
|
||||||
|
|
||||||
|
// Delete user from auth.users using admin API
|
||||||
|
const { error: deleteError } = await supabase.auth.admin.deleteUser(user_id);
|
||||||
|
|
||||||
|
if (deleteError) {
|
||||||
|
console.error('Error deleting user from auth.users:', deleteError);
|
||||||
|
throw new Error(`Failed to delete user from auth: ${deleteError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully deleted user: ${user_id}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, message: "User deleted successfully" }),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error deleting user:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, message: error.message }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
||||||
};
|
|
||||||
|
|
||||||
serve(async (req: Request): Promise<Response> => {
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
return new Response(null, { headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
|
||||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
|
||||||
|
|
||||||
// Get current date/time in Jakarta timezone
|
|
||||||
const now = new Date();
|
|
||||||
const jakartaOffset = 7 * 60; // UTC+7
|
|
||||||
const jakartaTime = new Date(now.getTime() + jakartaOffset * 60 * 1000);
|
|
||||||
const today = jakartaTime.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// Find consultations happening in the next 24 hours that haven't been reminded
|
|
||||||
const tomorrow = new Date(jakartaTime);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const tomorrowStr = tomorrow.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
console.log("Checking consultations for dates:", today, "to", tomorrowStr);
|
|
||||||
|
|
||||||
// Get confirmed slots for today and tomorrow
|
|
||||||
const { data: upcomingSlots, error: slotsError } = await supabase
|
|
||||||
.from("consulting_slots")
|
|
||||||
.select(`
|
|
||||||
*,
|
|
||||||
profiles:user_id (full_name, email)
|
|
||||||
`)
|
|
||||||
.eq("status", "confirmed")
|
|
||||||
.gte("date", today)
|
|
||||||
.lte("date", tomorrowStr)
|
|
||||||
.order("date")
|
|
||||||
.order("start_time");
|
|
||||||
|
|
||||||
if (slotsError) {
|
|
||||||
console.error("Error fetching slots:", slotsError);
|
|
||||||
throw slotsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Found upcoming slots:", upcomingSlots?.length || 0);
|
|
||||||
|
|
||||||
if (!upcomingSlots || upcomingSlots.length === 0) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ success: true, message: "No upcoming consultations to remind" }),
|
|
||||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get notification template for consultation reminder
|
|
||||||
const { data: template } = await supabase
|
|
||||||
.from("notification_templates")
|
|
||||||
.select("*")
|
|
||||||
.eq("key", "consulting_scheduled")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// Get SMTP settings
|
|
||||||
const { data: smtpSettings } = await supabase
|
|
||||||
.from("notification_settings")
|
|
||||||
.select("*")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// Get platform settings
|
|
||||||
const { data: platformSettings } = await supabase
|
|
||||||
.from("platform_settings")
|
|
||||||
.select("brand_name, brand_email_from_name, integration_whatsapp_number")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
const results: any[] = [];
|
|
||||||
|
|
||||||
for (const slot of upcomingSlots) {
|
|
||||||
const profile = slot.profiles as any;
|
|
||||||
|
|
||||||
// Build payload for notification
|
|
||||||
const payload = {
|
|
||||||
nama: profile?.full_name || "Pelanggan",
|
|
||||||
email: profile?.email || "",
|
|
||||||
tanggal_konsultasi: new Date(slot.date).toLocaleDateString("id-ID", {
|
|
||||||
weekday: "long",
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
}),
|
|
||||||
jam_konsultasi: `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)} WIB`,
|
|
||||||
link_meet: slot.meet_link || "Akan diinformasikan",
|
|
||||||
topik: slot.topic_category,
|
|
||||||
catatan: slot.notes || "-",
|
|
||||||
brand_name: platformSettings?.brand_name || "LearnHub",
|
|
||||||
whatsapp: platformSettings?.integration_whatsapp_number || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log the reminder payload
|
|
||||||
console.log("Reminder payload for slot:", slot.id, payload);
|
|
||||||
|
|
||||||
// Update last_payload_example in template
|
|
||||||
if (template) {
|
|
||||||
await supabase
|
|
||||||
.from("notification_templates")
|
|
||||||
.update({ last_payload_example: payload })
|
|
||||||
.eq("id", template.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send webhook if configured
|
|
||||||
if (template?.webhook_url) {
|
|
||||||
try {
|
|
||||||
await fetch(template.webhook_url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
event: "consulting_reminder",
|
|
||||||
slot_id: slot.id,
|
|
||||||
...payload,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
console.log("Webhook sent for slot:", slot.id);
|
|
||||||
} catch (webhookError) {
|
|
||||||
console.error("Webhook error:", webhookError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email if template is active and Mailketing is configured
|
|
||||||
if (template?.is_active && smtpSettings?.api_token && profile?.email) {
|
|
||||||
try {
|
|
||||||
// Replace shortcodes in email body using master template system
|
|
||||||
let emailBody = template.email_body_html || "";
|
|
||||||
let emailSubject = template.email_subject || "Reminder Konsultasi";
|
|
||||||
|
|
||||||
Object.entries(payload).forEach(([key, value]) => {
|
|
||||||
const regex = new RegExp(`\\{${key}\\}`, "g");
|
|
||||||
emailBody = emailBody.replace(regex, String(value));
|
|
||||||
emailSubject = emailSubject.replace(regex, String(value));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send via send-email-v2 (Mailketing API)
|
|
||||||
const { error: emailError } = await supabase.functions.invoke("send-email-v2", {
|
|
||||||
body: {
|
|
||||||
recipient: profile.email,
|
|
||||||
api_token: smtpSettings.api_token,
|
|
||||||
from_name: smtpSettings.from_name || platformSettings?.brand_name || "Access Hub",
|
|
||||||
from_email: smtpSettings.from_email || "noreply@with.dwindi.com",
|
|
||||||
subject: emailSubject,
|
|
||||||
content: emailBody,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (emailError) {
|
|
||||||
console.error("Failed to send reminder email:", emailError);
|
|
||||||
} else {
|
|
||||||
console.log("Reminder email sent to:", profile.email);
|
|
||||||
}
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error("Error sending reminder email:", emailError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
slot_id: slot.id,
|
|
||||||
client: profile?.full_name,
|
|
||||||
date: slot.date,
|
|
||||||
time: slot.start_time,
|
|
||||||
reminded: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
message: `Processed ${results.length} consultation reminders`,
|
|
||||||
results
|
|
||||||
}),
|
|
||||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error sending reminders:", error);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ success: false, message: error.message }),
|
|
||||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
import { 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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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 $$;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Add platform_url column to platform_settings
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Add platform_url column if it doesn't exist
|
||||||
|
ALTER TABLE platform_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS platform_url TEXT;
|
||||||
|
|
||||||
|
-- Set default value if null
|
||||||
|
UPDATE platform_settings
|
||||||
|
SET platform_url = 'https://access-hub.com'
|
||||||
|
WHERE platform_url IS NULL;
|
||||||
|
|
||||||
|
-- Add comment
|
||||||
|
COMMENT ON COLUMN platform_settings.platform_url IS 'Base URL of the platform (used for email links)';
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Add test_email template for "Uji Coba Email" button in Integrasi tab
|
||||||
|
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
'test_email',
|
||||||
|
'Test Email',
|
||||||
|
'Email Test - {platform_name}',
|
||||||
|
'
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #333;">Email Test - {platform_name}</h2>
|
||||||
|
|
||||||
|
<p>Halo,</p>
|
||||||
|
|
||||||
|
<p>Ini adalah email tes dari sistem <strong>{platform_name}</strong>.</p>
|
||||||
|
|
||||||
|
<div style="margin: 20px 0; padding: 15px; background-color: #f0f0f0; border-left: 4px solid #000;">
|
||||||
|
<p style="margin: 0; font-size: 14px;">
|
||||||
|
<strong>✓ Konfigurasi email berhasil!</strong><br>
|
||||||
|
Email Anda telah terkirim dengan benar menggunakan provider: <strong>Mailketing</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #666;">
|
||||||
|
Jika Anda menerima email ini, berarti konfigurasi email sudah benar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size: 14px;">
|
||||||
|
Terima kasih,<br>
|
||||||
|
Tim {platform_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
email_subject = EXCLUDED.email_subject,
|
||||||
|
email_body_html = EXCLUDED.email_body_html,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Verify the template
|
||||||
|
SELECT
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
email_subject,
|
||||||
|
is_active
|
||||||
|
FROM notification_templates
|
||||||
|
WHERE key = 'test_email';
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Fix Email Templates: Use Short Order ID and Add Missing Links
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. Fix order_created template - use short order_id and fix subject
|
||||||
|
UPDATE notification_templates
|
||||||
|
SET
|
||||||
|
email_subject = 'Konfirmasi Pesanan - #{order_id_short}',
|
||||||
|
email_body_html = '---
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #333;">Konfirmasi Pesanan</h2>
|
||||||
|
|
||||||
|
<p>Halo {nama},</p>
|
||||||
|
|
||||||
|
<p>Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:</p>
|
||||||
|
|
||||||
|
<!-- Order Summary Section -->
|
||||||
|
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>Order ID:</strong> #{order_id_short}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>Tanggal:</strong> {tanggal_pesanan}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>Produk:</strong> {produk}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>Metode Pembayaran:</strong> {metode_pembayaran}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 15px 0 5px 0; font-size: 16px; font-weight: bold; color: #000;">
|
||||||
|
Total: {total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0; padding: 20px; background-color: #f5f5f5; border-radius: 8px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
|
||||||
|
Silakan selesaikan pembayaran Anda dengan mengklik tombol di bawah:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{payment_link}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||||
|
Bayar Sekarang
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #666;">
|
||||||
|
Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size: 14px;">
|
||||||
|
Jika Anda memiliki pertanyaan, jangan ragu untuk menghubungi kami.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size: 14px;">
|
||||||
|
Terima kasih,<br>
|
||||||
|
Tim {platform_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
---',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE key = 'order_created';
|
||||||
|
|
||||||
|
-- 2. Create or update payment_success template
|
||||||
|
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
'payment_success',
|
||||||
|
'Payment Success Email',
|
||||||
|
'Pembayaran Berhasil - Order #{order_id_short}',
|
||||||
|
'---
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #28a745;">Pembayaran Berhasil! ✓</h2>
|
||||||
|
|
||||||
|
<p>Halo {nama},</p>
|
||||||
|
|
||||||
|
<p>Terima kasih! Pembayaran Anda telah berhasil dikonfirmasi.</p>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>Order ID:</strong> #{order_id_short}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>Tanggal:</strong> {tanggal_pesanan}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>Produk:</strong> {produk}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>Metode Pembayaran:</strong> {metode_pembayaran}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 15px 0 5px 0; font-size: 16px; font-weight: bold; color: #28a745;">
|
||||||
|
Total: {total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border-radius: 8px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
|
||||||
|
Akses ke produk Anda sudah aktif! Klik tombol di bawah untuk mulai belajar:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{link_akses}" style="display: inline-block; padding: 12px 24px; background-color: #28a745; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||||
|
Akses Sekarang
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #666;">
|
||||||
|
Jika Anda mengalami masalah saat mengakses produk, jangan ragu untuk menghubungi kami.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size: 14px;">
|
||||||
|
Selamat belajar!<br>
|
||||||
|
Tim {platform_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
---',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
email_subject = EXCLUDED.email_subject,
|
||||||
|
email_body_html = EXCLUDED.email_body_html,
|
||||||
|
is_active = EXCLUDED.is_active,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- 3. Create or update access_granted template
|
||||||
|
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
'access_granted',
|
||||||
|
'Access Granted Email',
|
||||||
|
'Akses Produk Diberikan - {produk}',
|
||||||
|
'---
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #28a745;">Akses Produk Aktif! 🎉</h2>
|
||||||
|
|
||||||
|
<p>Halo {nama},</p>
|
||||||
|
|
||||||
|
<p>Selamat! Akses ke produk Anda telah diaktifkan.</p>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border: 1px solid #b3d9ff; border-radius: 8px;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Produk Anda:</h3>
|
||||||
|
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
<strong>{produk}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border-radius: 8px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
|
||||||
|
Mulai belajar sekarang dengan mengklik tombol di bawah:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{link_akses}" style="display: inline-block; padding: 12px 24px; background-color: #28a745; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||||
|
Akses Sekarang
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #666;">
|
||||||
|
Nikmati pembelajaran Anda! Jika ada pertanyaan, jangan ragu untuk menghubungi kami.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size: 14px;">
|
||||||
|
Happy learning!<br>
|
||||||
|
Tim {platform_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
---',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
email_subject = EXCLUDED.email_subject,
|
||||||
|
email_body_html = EXCLUDED.email_body_html,
|
||||||
|
is_active = EXCLUDED.is_active,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- Verify updates
|
||||||
|
SELECT
|
||||||
|
key,
|
||||||
|
email_subject,
|
||||||
|
is_active,
|
||||||
|
updated_at
|
||||||
|
FROM notification_templates
|
||||||
|
WHERE key IN ('order_created', 'payment_success', 'access_granted')
|
||||||
|
ORDER BY key;
|
||||||
@@ -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> |
|
|
||||||
<a href="#" style="color: #000; text-decoration: underline;">Unsubscribe</a>
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user