diff --git a/src/App.tsx b/src/App.tsx index df62fa3..9d38f82 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import ProductDetail from "./pages/ProductDetail"; import Checkout from "./pages/Checkout"; import Bootcamp from "./pages/Bootcamp"; import Events from "./pages/Events"; +import ConsultingBooking from "./pages/ConsultingBooking"; import NotFound from "./pages/NotFound"; // Member pages @@ -28,6 +29,7 @@ import AdminOrders from "./pages/admin/AdminOrders"; import AdminMembers from "./pages/admin/AdminMembers"; import AdminEvents from "./pages/admin/AdminEvents"; import AdminSettings from "./pages/admin/AdminSettings"; +import AdminConsulting from "./pages/admin/AdminConsulting"; const queryClient = new QueryClient(); @@ -47,6 +49,7 @@ const App = () => ( } /> } /> } /> + } /> {/* Member routes */} } /> @@ -62,6 +65,7 @@ const App = () => ( } /> } /> } /> + } /> } /> diff --git a/src/components/admin/settings/BrandingTab.tsx b/src/components/admin/settings/BrandingTab.tsx new file mode 100644 index 0000000..e118c61 --- /dev/null +++ b/src/components/admin/settings/BrandingTab.tsx @@ -0,0 +1,239 @@ +import { useEffect, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { toast } from '@/hooks/use-toast'; +import { Palette, Image, Mail } from 'lucide-react'; + +interface PlatformSettings { + id?: string; + brand_name: string; + brand_tagline: string; + brand_logo_url: string; + brand_favicon_url: string; + brand_primary_color: string; + brand_accent_color: string; + brand_email_from_name: string; +} + +const emptySettings: PlatformSettings = { + brand_name: '', + brand_tagline: '', + brand_logo_url: '', + brand_favicon_url: '', + brand_primary_color: '#111827', + brand_accent_color: '#0F766E', + brand_email_from_name: '', +}; + +export function BrandingTab() { + const [settings, setSettings] = useState(emptySettings); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetchSettings(); + }, []); + + const fetchSettings = async () => { + const { data, error } = await supabase + .from('platform_settings') + .select('*') + .single(); + + if (data) { + setSettings({ + id: data.id, + brand_name: data.brand_name || '', + brand_tagline: data.brand_tagline || '', + brand_logo_url: data.brand_logo_url || '', + brand_favicon_url: data.brand_favicon_url || '', + brand_primary_color: data.brand_primary_color || '#111827', + brand_accent_color: data.brand_accent_color || '#0F766E', + brand_email_from_name: data.brand_email_from_name || '', + }); + } + setLoading(false); + }; + + const saveSettings = async () => { + setSaving(true); + const payload = { ...settings }; + delete payload.id; + + if (settings.id) { + const { error } = await supabase + .from('platform_settings') + .update(payload) + .eq('id', settings.id); + + if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' }); + else toast({ title: 'Berhasil', description: 'Pengaturan branding disimpan' }); + } else { + const { data, error } = await supabase + .from('platform_settings') + .insert(payload) + .select() + .single(); + + if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' }); + else { + setSettings({ ...settings, id: data.id }); + toast({ title: 'Berhasil', description: 'Pengaturan branding disimpan' }); + } + } + setSaving(false); + }; + + if (loading) return
; + + return ( +
+ + + + + Identitas Brand + + + Konfigurasi nama, tagline, dan tampilan platform + + + +
+
+ + setSettings({ ...settings, brand_name: e.target.value })} + placeholder="LearnHub" + className="border-2" + /> +

+ Ditampilkan di sidebar, header, dan email +

+
+ +
+ + setSettings({ ...settings, brand_tagline: e.target.value })} + placeholder="Belajar bareng, dari praktisi." + className="border-2" + /> +
+
+ +
+
+ + setSettings({ ...settings, brand_logo_url: e.target.value })} + placeholder="https://example.com/logo.png" + className="border-2" + /> + {settings.brand_logo_url && ( +
+ Logo preview (e.currentTarget.style.display = 'none')} + /> +
+ )} +
+ +
+ + setSettings({ ...settings, brand_favicon_url: e.target.value })} + placeholder="https://example.com/favicon.ico" + className="border-2" + /> + {settings.brand_favicon_url && ( +
+ Favicon preview (e.currentTarget.style.display = 'none')} + /> +
+ )} +
+
+ +
+
+ +
+ setSettings({ ...settings, brand_primary_color: e.target.value })} + className="w-16 h-10 p-1 border-2" + /> + setSettings({ ...settings, brand_primary_color: e.target.value })} + placeholder="#111827" + className="border-2 flex-1" + /> +
+
+ +
+ +
+ setSettings({ ...settings, brand_accent_color: e.target.value })} + className="w-16 h-10 p-1 border-2" + /> + setSettings({ ...settings, brand_accent_color: e.target.value })} + placeholder="#0F766E" + className="border-2 flex-1" + /> +
+
+
+ +
+ + setSettings({ ...settings, brand_email_from_name: e.target.value })} + placeholder="LearnHub Team" + className="border-2" + /> +

+ Digunakan jika SMTP from_name kosong +

+
+ + +
+
+
+ ); +} diff --git a/src/components/admin/settings/IntegrasiTab.tsx b/src/components/admin/settings/IntegrasiTab.tsx new file mode 100644 index 0000000..baf0829 --- /dev/null +++ b/src/components/admin/settings/IntegrasiTab.tsx @@ -0,0 +1,280 @@ +import { useEffect, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { toast } from '@/hooks/use-toast'; +import { Puzzle, Webhook, MessageSquare, Calendar, Mail, Link as LinkIcon } from 'lucide-react'; + +interface IntegrationSettings { + id?: string; + integration_n8n_base_url: string; + integration_whatsapp_number: string; + integration_whatsapp_url: string; + integration_google_calendar_id: string; + integration_email_provider: string; + integration_email_api_base_url: string; + integration_privacy_url: string; + integration_terms_url: string; +} + +const emptySettings: IntegrationSettings = { + integration_n8n_base_url: '', + integration_whatsapp_number: '', + integration_whatsapp_url: '', + integration_google_calendar_id: '', + integration_email_provider: 'smtp', + integration_email_api_base_url: '', + integration_privacy_url: '/privacy', + integration_terms_url: '/terms', +}; + +export function IntegrasiTab() { + const [settings, setSettings] = useState(emptySettings); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetchSettings(); + }, []); + + const fetchSettings = async () => { + const { data, error } = await supabase + .from('platform_settings') + .select('*') + .single(); + + if (data) { + setSettings({ + id: data.id, + integration_n8n_base_url: data.integration_n8n_base_url || '', + integration_whatsapp_number: data.integration_whatsapp_number || '', + integration_whatsapp_url: data.integration_whatsapp_url || '', + integration_google_calendar_id: data.integration_google_calendar_id || '', + integration_email_provider: data.integration_email_provider || 'smtp', + integration_email_api_base_url: data.integration_email_api_base_url || '', + integration_privacy_url: data.integration_privacy_url || '/privacy', + integration_terms_url: data.integration_terms_url || '/terms', + }); + } + setLoading(false); + }; + + const saveSettings = async () => { + setSaving(true); + const payload = { ...settings }; + delete payload.id; + + if (settings.id) { + const { error } = await supabase + .from('platform_settings') + .update(payload) + .eq('id', settings.id); + + if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' }); + else toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' }); + } else { + const { data, error } = await supabase + .from('platform_settings') + .insert(payload) + .select() + .single(); + + if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' }); + else { + setSettings({ ...settings, id: data.id }); + toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' }); + } + } + setSaving(false); + }; + + if (loading) return
; + + return ( +
+ {/* n8n / Webhook */} + + + + + n8n / Webhook + + + Konfigurasi URL untuk integrasi otomatisasi + + + +
+ + setSettings({ ...settings, integration_n8n_base_url: e.target.value })} + placeholder="https://automation.domain.com" + className="border-2" + /> +

+ Digunakan sebagai target default untuk webhook lanjutan. webhook_url per template tetap harus URL lengkap. +

+
+
+
+ + {/* WhatsApp */} + + + + + WhatsApp + + + Nomor kontak untuk dukungan dan notifikasi + + + +
+
+ + setSettings({ ...settings, integration_whatsapp_number: e.target.value })} + placeholder="+62812xxxx" + className="border-2" + /> +

+ Ditampilkan di konfirmasi konsultasi +

+
+ +
+ + setSettings({ ...settings, integration_whatsapp_url: e.target.value })} + placeholder="https://wa.me/62812..." + className="border-2" + /> +
+
+
+
+ + {/* Google Calendar */} + + + + + Google Calendar + + + Untuk pembuatan event konsultasi otomatis + + + +
+ + setSettings({ ...settings, integration_google_calendar_id: e.target.value })} + placeholder="your-calendar@gmail.com" + className="border-2" + /> +

+ Backend/n8n akan menggunakan ID ini untuk membuat event +

+
+
+
+ + {/* Email Provider */} + + + + + Provider Email (Opsional) + + + Konfigurasi alternatif selain SMTP + + + +
+
+ + +
+ +
+ + setSettings({ ...settings, integration_email_api_base_url: e.target.value })} + placeholder="https://api.resend.com" + className="border-2" + disabled={settings.integration_email_provider === 'smtp'} + /> +
+
+
+
+ + {/* Public Links */} + + + + + Link Publik + + + URL untuk halaman legal + + + +
+
+ + setSettings({ ...settings, integration_privacy_url: e.target.value })} + placeholder="/privacy" + className="border-2" + /> +
+ +
+ + setSettings({ ...settings, integration_terms_url: e.target.value })} + placeholder="/terms" + className="border-2" + /> +
+
+

+ Default: halaman internal. Bisa diganti dengan URL eksternal. +

+
+
+ + +
+ ); +} diff --git a/src/components/admin/settings/NotifikasiTab.tsx b/src/components/admin/settings/NotifikasiTab.tsx index 07a3769..75f7dce 100644 --- a/src/components/admin/settings/NotifikasiTab.tsx +++ b/src/components/admin/settings/NotifikasiTab.tsx @@ -41,14 +41,49 @@ const SHORTCODES_HELP = { event: ['{judul_event}', '{tanggal_event}', '{jam_event}', '{link_event}'], }; -const DEFAULT_TEMPLATES = [ - { key: 'payment_success', name: 'Pembayaran Berhasil' }, - { key: 'access_granted', name: 'Akses Produk Diberikan' }, - { key: 'order_created', name: 'Pesanan Dibuat' }, - { key: 'payment_reminder', name: 'Pengingat Pembayaran' }, - { key: 'consulting_scheduled', name: 'Konsultasi Terjadwal' }, - { key: 'event_reminder', name: 'Reminder Webinar/Bootcamp' }, - { key: 'bootcamp_progress', name: 'Progress Bootcamp' }, +const DEFAULT_TEMPLATES: { key: string; name: string; defaultSubject: string; defaultBody: string }[] = [ + { + key: 'payment_success', + name: 'Pembayaran Berhasil', + defaultSubject: 'Pembayaran Berhasil - Order #{order_id}', + defaultBody: '

Halo {nama}!

Terima kasih, pembayaran Anda sebesar {total} telah berhasil dikonfirmasi.

Detail Pesanan:

  • Order ID: {order_id}
  • Tanggal: {tanggal_pesanan}
  • Metode: {metode_pembayaran}

Produk: {produk}

' + }, + { + key: 'access_granted', + name: 'Akses Produk Diberikan', + defaultSubject: 'Akses Anda Sudah Aktif - {produk}', + defaultBody: '

Halo {nama}!

Selamat! Akses Anda ke {produk} sudah aktif.

Akses Sekarang

' + }, + { + key: 'order_created', + name: 'Pesanan Dibuat', + defaultSubject: 'Pesanan Anda #{order_id} Sedang Diproses', + defaultBody: '

Halo {nama}!

Pesanan Anda dengan nomor {order_id} telah kami terima.

Total: {total}

Silakan selesaikan pembayaran sebelum batas waktu.

' + }, + { + key: 'payment_reminder', + name: 'Pengingat Pembayaran', + defaultSubject: 'Jangan Lupa Bayar - Order #{order_id}', + defaultBody: '

Halo {nama}!

Pesanan Anda dengan nomor {order_id} menunggu pembayaran.

Total: {total}

Segera selesaikan pembayaran agar tidak kedaluwarsa.

' + }, + { + key: 'consulting_scheduled', + name: 'Konsultasi Terjadwal', + defaultSubject: 'Konsultasi Anda Sudah Terjadwal - {tanggal_konsultasi}', + defaultBody: '

Halo {nama}!

Sesi konsultasi Anda telah dikonfirmasi:

  • Tanggal: {tanggal_konsultasi}
  • Jam: {jam_konsultasi}

Link meeting: {link_meet}

Jika ada pertanyaan, hubungi kami.

' + }, + { + key: 'event_reminder', + name: 'Reminder Webinar/Bootcamp', + defaultSubject: 'Reminder: {judul_event} Dimulai {tanggal_event}', + defaultBody: '

Halo {nama}!

Jangan lupa, {judul_event} akan dimulai:

  • Tanggal: {tanggal_event}
  • Jam: {jam_event}

Bergabung

' + }, + { + key: 'bootcamp_progress', + name: 'Progress Bootcamp', + defaultSubject: 'Update Progress Bootcamp Anda', + defaultBody: '

Halo {nama}!

Ini adalah update progress bootcamp Anda.

Terus semangat belajar!

' + }, ]; const emptySmtp: SmtpSettings = { @@ -94,8 +129,8 @@ export function NotifikasiTab() { key: t.key, name: t.name, is_active: false, - email_subject: '', - email_body_html: '', + email_subject: t.defaultSubject, + email_body_html: t.defaultBody, webhook_url: '', })); const { data, error } = await supabase.from('notification_templates').insert(toInsert).select(); @@ -261,8 +296,8 @@ export function NotifikasiTab() { -
-

Shortcode yang tersedia:

+
+

Shortcode yang tersedia:

Umum: {SHORTCODES_HELP.common.join(', ')} @@ -277,6 +312,11 @@ export function NotifikasiTab() { Event: {SHORTCODES_HELP.event.join(', ')}
+

+ Penting: Toggle "Aktifkan" hanya mengontrol pengiriman email. + Jika webhook_url diisi, sistem tetap akan mengirim payload ke URL tersebut + meskipun email dinonaktifkan. +

{templates.map((template) => ( diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx index 49a2f22..beddaae 100644 --- a/src/pages/Products.tsx +++ b/src/pages/Products.tsx @@ -9,6 +9,7 @@ import { useCart } from '@/contexts/CartContext'; import { toast } from '@/hooks/use-toast'; import { Skeleton } from '@/components/ui/skeleton'; import { formatIDR } from '@/lib/format'; +import { Video } from 'lucide-react'; interface Product { id: string; @@ -21,27 +22,45 @@ interface Product { is_active: boolean; } +interface ConsultingSettings { + is_consulting_enabled: boolean; + consulting_block_price: number; + consulting_block_duration_minutes: number; +} + export default function Products() { const [products, setProducts] = useState([]); + const [consultingSettings, setConsultingSettings] = useState(null); const [loading, setLoading] = useState(true); const { addItem, items } = useCart(); useEffect(() => { - fetchProducts(); + fetchData(); }, []); - const fetchProducts = async () => { - const { data, error } = await supabase - .from('products') - .select('*') - .eq('is_active', true) - .order('created_at', { ascending: false }); + const fetchData = async () => { + const [productsRes, consultingRes] = await Promise.all([ + supabase + .from('products') + .select('*') + .eq('is_active', true) + .order('created_at', { ascending: false }), + supabase + .from('consulting_settings') + .select('is_consulting_enabled, consulting_block_price, consulting_block_duration_minutes') + .single(), + ]); - if (error) { + if (productsRes.error) { toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' }); } else { - setProducts(data || []); + setProducts(productsRes.data || []); } + + if (consultingRes.data) { + setConsultingSettings(consultingRes.data); + } + setLoading(false); }; @@ -87,12 +106,42 @@ export default function Products() { ))}
- ) : products.length === 0 ? ( -
-

Belum ada produk tersedia.

-
) : (
+ {/* Consulting Card - Only show when enabled */} + {consultingSettings?.is_consulting_enabled && ( + + +
+ + + Konsultasi +
+ + Sesi konsultasi pribadi dengan mentor. Pilih waktu dan durasi sesuai kebutuhan Anda. + +
+ +
+ + {formatIDR(consultingSettings.consulting_block_price)} + + + / {consultingSettings.consulting_block_duration_minutes} menit + +
+ + + +
+
+ )} + + {/* Regular Products */} {products.map((product) => ( @@ -128,6 +177,12 @@ export default function Products() { ))} + + {products.length === 0 && !consultingSettings?.is_consulting_enabled && ( +
+

Belum ada produk tersedia.

+
+ )}
)}
diff --git a/src/pages/admin/AdminSettings.tsx b/src/pages/admin/AdminSettings.tsx index 24517cf..521d8e1 100644 --- a/src/pages/admin/AdminSettings.tsx +++ b/src/pages/admin/AdminSettings.tsx @@ -7,6 +7,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { WorkhoursTab } from '@/components/admin/settings/WorkhoursTab'; import { NotifikasiTab } from '@/components/admin/settings/NotifikasiTab'; import { KonsultasiTab } from '@/components/admin/settings/KonsultasiTab'; +import { BrandingTab } from '@/components/admin/settings/BrandingTab'; +import { IntegrasiTab } from '@/components/admin/settings/IntegrasiTab'; import { Clock, Bell, Video, Palette, Puzzle } from 'lucide-react'; export default function AdminSettings() { @@ -51,11 +53,11 @@ export default function AdminSettings() {
diff --git a/supabase/functions/pakasir-webhook/index.ts b/supabase/functions/pakasir-webhook/index.ts index eedc554..a757f1b 100644 --- a/supabase/functions/pakasir-webhook/index.ts +++ b/supabase/functions/pakasir-webhook/index.ts @@ -1,22 +1,17 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; +import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; +import { SMTPClient } from "https://deno.land/x/denomailer@1.6.0/mod.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-pakasir-signature, x-callback-token", }; -// TODO: Set these in your Supabase Edge Function secrets +// Environment variables const PAKASIR_WEBHOOK_SECRET = Deno.env.get("PAKASIR_WEBHOOK_SECRET") || ""; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; -// Email template placeholder - will be replaced with real provider later -const ORDER_PAID_EMAIL_TEMPLATE = Deno.env.get("ORDER_PAID_EMAIL_TEMPLATE") || JSON.stringify({ - subject: "Pembayaran Berhasil - {{order_id}}", - body: "Terima kasih! Pembayaran untuk pesanan {{order_id}} sebesar Rp {{amount}} telah berhasil. Anda sekarang memiliki akses ke: {{products}}." -}); - interface PakasirWebhookPayload { amount: number; order_id: string; @@ -26,33 +21,201 @@ interface PakasirWebhookPayload { completed_at?: string; } -// Placeholder email function - logs for now, will be replaced with real provider -async function sendOrderPaidEmail( - userEmail: string, - order: { id: string; total_amount: number }, - products: string[] -): Promise { - try { - const template = JSON.parse(ORDER_PAID_EMAIL_TEMPLATE); - - const subject = template.subject - .replace("{{order_id}}", order.id.substring(0, 8)) - .replace("{{amount}}", order.total_amount.toLocaleString("id-ID")); - - const body = template.body - .replace("{{order_id}}", order.id.substring(0, 8)) - .replace("{{amount}}", order.total_amount.toLocaleString("id-ID")) - .replace("{{products}}", products.join(", ")); +interface SmtpSettings { + smtp_host: string; + smtp_port: number; + smtp_username: string; + smtp_password: string; + smtp_from_name: string; + smtp_from_email: string; + smtp_use_tls: boolean; +} - console.log("[EMAIL] Would send to:", userEmail); - console.log("[EMAIL] Subject:", subject); - console.log("[EMAIL] Body:", body); - - // TODO: Replace with actual email provider call (e.g., Resend, SendGrid) - // await emailProvider.send({ to: userEmail, subject, body }); - } catch (error) { - console.error("[EMAIL] Error preparing email:", error); +interface NotificationTemplate { + id: string; + key: string; + name: string; + is_active: boolean; + email_subject: string; + email_body_html: string; + webhook_url: string; +} + +// Replace shortcodes in template +function replaceShortcodes(template: string, data: Record): string { + let result = template || ""; + for (const [key, value] of Object.entries(data)) { + result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value || ''); } + return result; +} + +// Send email via SMTP +async function sendEmail( + smtp: SmtpSettings, + to: string, + subject: string, + htmlBody: string, + brandFromName?: string +): Promise { + if (!smtp.smtp_host || !smtp.smtp_username || !smtp.smtp_password) { + console.log("[EMAIL] SMTP not configured, skipping email"); + return false; + } + + try { + const client = new SMTPClient({ + connection: { + hostname: smtp.smtp_host, + port: smtp.smtp_port, + tls: smtp.smtp_use_tls, + auth: { + username: smtp.smtp_username, + password: smtp.smtp_password, + }, + }, + }); + + const fromName = smtp.smtp_from_name || brandFromName || "Notification"; + const fromEmail = smtp.smtp_from_email || smtp.smtp_username; + + await client.send({ + from: `${fromName} <${fromEmail}>`, + to: to, + subject: subject, + content: "auto", + html: htmlBody, + }); + + await client.close(); + console.log("[EMAIL] Sent successfully to:", to); + return true; + } catch (error) { + console.error("[EMAIL] Failed to send:", error); + return false; + } +} + +// Send webhook notification +async function sendWebhook(url: string, payload: Record): Promise { + if (!url) return false; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + console.log("[WEBHOOK] Sent to:", url, "Status:", response.status); + return response.ok; + } catch (error) { + console.error("[WEBHOOK] Failed to send to:", url, error); + return false; + } +} + +// Process notification for a specific event +async function processNotification( + supabase: SupabaseClient, + templateKey: string, + recipientEmail: string, + shortcodeData: Record, + webhookPayload: Record +): Promise { + console.log(`[NOTIFICATION] Processing ${templateKey} for ${recipientEmail}`); + + // Fetch template + const { data: templateData } = await supabase + .from("notification_templates") + .select("*") + .eq("key", templateKey) + .single(); + + if (!templateData) { + console.log(`[NOTIFICATION] Template ${templateKey} not found`); + return; + } + + const template = templateData as NotificationTemplate; + + // ALWAYS send webhook if URL is configured (regardless of is_active) + if (template.webhook_url) { + await sendWebhook(template.webhook_url, webhookPayload); + + // Update last_payload_example + await supabase + .from("notification_templates") + .update({ last_payload_example: webhookPayload, updated_at: new Date().toISOString() }) + .eq("id", template.id); + } + + // Only send email if is_active is true + if (!template.is_active) { + console.log(`[NOTIFICATION] Template ${templateKey} is not active, skipping email`); + return; + } + + // Fetch SMTP settings + const { data: smtpData } = await supabase + .from("notification_settings") + .select("*") + .single(); + + if (!smtpData) { + console.log("[NOTIFICATION] SMTP settings not configured"); + return; + } + + const smtpSettings = smtpData as SmtpSettings; + + // Fetch brand settings for fallback from_name + const { data: platformData } = await supabase + .from("platform_settings") + .select("brand_email_from_name") + .single(); + + const brandFromName = (platformData as { brand_email_from_name?: string } | null)?.brand_email_from_name; + + // Replace shortcodes + const subject = replaceShortcodes(template.email_subject, shortcodeData); + const body = replaceShortcodes(template.email_body_html, shortcodeData); + + // Wrap body in email template + const fullHtml = ` + + + + + + + + +
+
+ ${body} +
+ +
+ +`; + + await sendEmail( + smtpSettings, + recipientEmail, + subject, + fullHtml, + brandFromName + ); } serve(async (req) => { @@ -143,64 +306,152 @@ serve(async (req) => { console.log("[WEBHOOK] Order updated to paid:", order.id); - // Get order items to grant access - const { data: orderItems, error: itemsError } = await supabase - .from("order_items") - .select("product_id, product:products(title)") - .eq("order_id", order.id); - - if (itemsError) { - console.error("[WEBHOOK] Failed to fetch order items:", itemsError); - } - - const productTitles: string[] = []; - - // Grant user_access for each product - if (orderItems && orderItems.length > 0) { - for (const item of orderItems) { - const productId = item.product_id; - // Supabase joins can return array or single object depending on relationship - const productData = Array.isArray(item.product) ? item.product[0] : item.product; - - // Check if access already exists - const { data: existingAccess } = await supabase - .from("user_access") - .select("id") - .eq("user_id", order.user_id) - .eq("product_id", productId) - .maybeSingle(); - - if (!existingAccess) { - const { error: accessError } = await supabase - .from("user_access") - .insert({ - user_id: order.user_id, - product_id: productId, - }); - - if (accessError) { - console.error("[WEBHOOK] Failed to grant access for product:", productId, accessError); - } else { - console.log("[WEBHOOK] Granted access for product:", productId); - } - } - - if (productData?.title) { - productTitles.push(productData.title); - } - } - } - - // Get user email for notification + // Get user profile const { data: profile } = await supabase .from("profiles") - .select("email") + .select("full_name, email") .eq("id", order.user_id) .single(); - // Send email notification (placeholder) - if (profile?.email) { - await sendOrderPaidEmail(profile.email, order, productTitles); + const userEmail = (profile as { email?: string } | null)?.email || ""; + const userName = (profile as { full_name?: string } | null)?.full_name || "Pelanggan"; + + // Check for consulting slots linked to this order + const { data: consultingSlots } = await supabase + .from("consulting_slots") + .select("*") + .eq("order_id", order.id); + + if (consultingSlots && consultingSlots.length > 0) { + // This is a consulting order - update slot statuses + const { error: slotUpdateError } = await supabase + .from("consulting_slots") + .update({ status: "confirmed" }) + .eq("order_id", order.id); + + if (slotUpdateError) { + console.error("[WEBHOOK] Failed to update consulting slots:", slotUpdateError); + } else { + console.log("[WEBHOOK] Consulting slots confirmed for order:", order.id); + } + + // Format consulting slot details for notification + const slots = consultingSlots as Array<{ date: string; start_time: string; end_time: string; meet_link?: string }>; + + const shortcodeData: Record = { + nama: userName, + email: userEmail, + order_id: order.id.substring(0, 8), + tanggal_pesanan: new Date().toLocaleDateString("id-ID"), + total: `Rp ${order.total_amount.toLocaleString("id-ID")}`, + metode_pembayaran: payload.payment_method || "Pakasir", + tanggal_konsultasi: slots[0]?.date || "", + jam_konsultasi: slots.map(s => s.start_time.substring(0, 5)).join(", "), + link_meet: slots[0]?.meet_link || "Akan dikirim terpisah", + }; + + const webhookPayload: Record = { + event: "consulting_scheduled", + order_id: order.id, + user_id: order.user_id, + user_email: userEmail, + user_name: userName, + total_amount: order.total_amount, + payment_method: payload.payment_method, + slots: consultingSlots, + timestamp: new Date().toISOString(), + }; + + // Send consulting_scheduled notification + await processNotification(supabase, "consulting_scheduled", userEmail, shortcodeData, webhookPayload); + + } else { + // Regular product order - grant access + const { data: orderItems, error: itemsError } = await supabase + .from("order_items") + .select("product_id, product:products(title)") + .eq("order_id", order.id); + + if (itemsError) { + console.error("[WEBHOOK] Failed to fetch order items:", itemsError); + } + + const productTitles: string[] = []; + + // Grant user_access for each product + if (orderItems && orderItems.length > 0) { + for (const item of orderItems as Array<{ product_id: string; product: { title: string } | { title: string }[] | null }>) { + const productId = item.product_id; + const productData = Array.isArray(item.product) ? item.product[0] : item.product; + + // Check if access already exists + const { data: existingAccess } = await supabase + .from("user_access") + .select("id") + .eq("user_id", order.user_id) + .eq("product_id", productId) + .maybeSingle(); + + if (!existingAccess) { + const { error: accessError } = await supabase + .from("user_access") + .insert({ + user_id: order.user_id, + product_id: productId, + }); + + if (accessError) { + console.error("[WEBHOOK] Failed to grant access for product:", productId, accessError); + } else { + console.log("[WEBHOOK] Granted access for product:", productId); + } + } + + if (productData?.title) { + productTitles.push(productData.title); + } + } + } + + const shortcodeData: Record = { + nama: userName, + email: userEmail, + order_id: order.id.substring(0, 8), + tanggal_pesanan: new Date().toLocaleDateString("id-ID"), + total: `Rp ${order.total_amount.toLocaleString("id-ID")}`, + metode_pembayaran: payload.payment_method || "Pakasir", + produk: productTitles.join(", "), + link_akses: `${Deno.env.get("SITE_URL") || ""}/access`, + }; + + const webhookPayload: Record = { + event: "payment_success", + order_id: order.id, + user_id: order.user_id, + user_email: userEmail, + user_name: userName, + total_amount: order.total_amount, + payment_method: payload.payment_method, + products: productTitles, + timestamp: new Date().toISOString(), + }; + + // Send payment_success notification + await processNotification(supabase, "payment_success", userEmail, shortcodeData, webhookPayload); + + // Also send access_granted notification + if (productTitles.length > 0) { + const accessWebhookPayload: Record = { + event: "access_granted", + order_id: order.id, + user_id: order.user_id, + user_email: userEmail, + user_name: userName, + products: productTitles, + timestamp: new Date().toISOString(), + }; + await processNotification(supabase, "access_granted", userEmail, shortcodeData, accessWebhookPayload); + } } return new Response(JSON.stringify({ success: true, order_id: order.id }), {