From e569c2cf7ea86e922de1957e414c908de257dcb0 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:02:31 +0000 Subject: [PATCH] Changes --- package-lock.json | 12 + package.json | 1 + src/App.tsx | 6 + src/components/RichTextEditor.tsx | 225 ++++++++++-- .../admin/settings/IntegrasiTab.tsx | 1 + src/pages/Calendar.tsx | 303 ++++++++++++++++ src/pages/Privacy.tsx | 89 +++++ src/pages/Terms.tsx | 115 ++++++ src/pages/member/OrderDetail.tsx | 107 ++++-- supabase/config.toml | 6 + supabase/functions/daily-reminders/index.ts | 138 ++++++++ supabase/functions/send-notification/index.ts | 327 ++++++++++++++++++ 12 files changed, 1282 insertions(+), 48 deletions(-) create mode 100644 src/pages/Calendar.tsx create mode 100644 src/pages/Privacy.tsx create mode 100644 src/pages/Terms.tsx create mode 100644 supabase/functions/daily-reminders/index.ts create mode 100644 supabase/functions/send-notification/index.ts diff --git a/package-lock.json b/package-lock.json index 2b05158..e09fee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "tiptap-extension-resize-image": "^1.3.2", "vaul": "^0.9.9", "zod": "^3.25.76" }, @@ -7235,6 +7236,17 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tiptap-extension-resize-image": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/tiptap-extension-resize-image/-/tiptap-extension-resize-image-1.3.2.tgz", + "integrity": "sha512-QkfFMmurcJbzHn+IQbJbaEheN04vbwAiGIiGTmjT/3q9h+2uOAILHNmYnaSv9UPUR6rPvBIBvVVmL8lD12rkJA==", + "license": "MIT", + "peerDependencies": { + "@tiptap/core": "^2.0.0 || ^3.0.0", + "@tiptap/extension-image": "^2.0.0 || ^3.0.0", + "@tiptap/pm": "^2.0.0 || ^3.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/package.json b/package.json index 425db0f..c980022 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "tiptap-extension-resize-image": "^1.3.2", "vaul": "^0.9.9", "zod": "^3.25.76" }, diff --git a/src/App.tsx b/src/App.tsx index f790e3c..6b5b84d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,9 @@ import Checkout from "./pages/Checkout"; import Bootcamp from "./pages/Bootcamp"; import Events from "./pages/Events"; import ConsultingBooking from "./pages/ConsultingBooking"; +import Calendar from "./pages/Calendar"; +import Privacy from "./pages/Privacy"; +import Terms from "./pages/Terms"; import NotFound from "./pages/NotFound"; // Member pages @@ -53,6 +56,9 @@ const App = () => ( } /> } /> } /> + } /> + } /> + } /> {/* Member routes */} } /> diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 5b194fe..3449a6d 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -6,12 +6,16 @@ import Placeholder from '@tiptap/extension-placeholder'; import { Button } from '@/components/ui/button'; import { Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon, - Image as ImageIcon, Heading1, Heading2, Undo, Redo + Image as ImageIcon, Heading1, Heading2, Undo, Redo, + Maximize2, Minimize2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { toast } from '@/hooks/use-toast'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; interface RichTextEditorProps { content: string; @@ -20,7 +24,37 @@ interface RichTextEditorProps { className?: string; } +// Custom Image extension with resize support +const ResizableImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: null, + parseHTML: element => element.getAttribute('width') || element.style.width?.replace('px', ''), + renderHTML: attributes => { + if (!attributes.width) return {}; + return { width: attributes.width, style: `width: ${attributes.width}px` }; + }, + }, + height: { + default: null, + parseHTML: element => element.getAttribute('height') || element.style.height?.replace('px', ''), + renderHTML: attributes => { + if (!attributes.height) return {}; + return { height: attributes.height, style: `height: ${attributes.height}px` }; + }, + }, + }; + }, +}); + export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) { + const [uploading, setUploading] = useState(false); + const [selectedImage, setSelectedImage] = useState<{ src: string; width?: number; height?: number } | null>(null); + const [imageWidth, setImageWidth] = useState(''); + const [imageHeight, setImageHeight] = useState(''); + const editor = useEditor({ extensions: [ StarterKit, @@ -30,9 +64,9 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. class: 'text-primary underline', }, }), - Image.configure({ + ResizableImage.configure({ HTMLAttributes: { - class: 'max-w-full h-auto rounded-md', + class: 'max-w-full h-auto rounded-md cursor-pointer', }, }), Placeholder.configure({ @@ -43,9 +77,25 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. onUpdate: ({ editor }) => { onChange(editor.getHTML()); }, + onSelectionUpdate: ({ editor }) => { + // Check if an image is selected + const { selection } = editor.state; + const node = editor.state.doc.nodeAt(selection.from); + if (node?.type.name === 'image') { + setSelectedImage({ + src: node.attrs.src, + width: node.attrs.width, + height: node.attrs.height, + }); + setImageWidth(node.attrs.width?.toString() || ''); + setImageHeight(node.attrs.height?.toString() || ''); + } else { + setSelectedImage(null); + } + }, }); - // Sync content when it changes externally (e.g., when editing different items) + // Sync content when it changes externally useEffect(() => { if (editor && content !== editor.getHTML()) { editor.commands.setContent(content || ''); @@ -60,18 +110,65 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. } }, [editor]); + const uploadImageToStorage = async (file: File): Promise => { + try { + const fileExt = file.name.split('.').pop(); + const fileName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${fileExt}`; + const filePath = `editor-images/${fileName}`; + + const { data, error } = await supabase.storage + .from('content') + .upload(filePath, file, { + cacheControl: '3600', + upsert: false, + }); + + if (error) { + console.error('Storage upload error:', error); + // Fall back to base64 if storage fails + return null; + } + + const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath); + return urlData.publicUrl; + } catch (err) { + console.error('Upload error:', err); + return null; + } + }; + + const convertToDataUrl = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + const handleImageUpload = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !editor) return; - // For now, convert to base64 data URL since storage bucket may not be configured - // In production, you would upload to Supabase Storage - const reader = new FileReader(); - reader.onload = () => { - const dataUrl = reader.result as string; - editor.chain().focus().setImage({ src: dataUrl }).run(); - }; - reader.readAsDataURL(file); + setUploading(true); + + // Try to upload to storage first + let imageUrl = await uploadImageToStorage(file); + + // Fall back to base64 if storage upload fails + if (!imageUrl) { + toast({ + title: 'Info', + description: 'Menggunakan penyimpanan lokal untuk gambar', + }); + imageUrl = await convertToDataUrl(file); + } + + editor.chain().focus().setImage({ src: imageUrl }).run(); + setUploading(false); + + // Reset input + e.target.value = ''; }, [editor]); const handlePaste = useCallback(async (e: React.ClipboardEvent) => { @@ -84,17 +181,47 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. const file = item.getAsFile(); if (!file) continue; - const reader = new FileReader(); - reader.onload = () => { - const dataUrl = reader.result as string; - editor.chain().focus().setImage({ src: dataUrl }).run(); - }; - reader.readAsDataURL(file); + setUploading(true); + + let imageUrl = await uploadImageToStorage(file); + if (!imageUrl) { + imageUrl = await convertToDataUrl(file); + } + + editor.chain().focus().setImage({ src: imageUrl }).run(); + setUploading(false); break; } } }, [editor]); + const updateImageSize = useCallback(() => { + if (!editor || !selectedImage) return; + + const width = imageWidth ? parseInt(imageWidth) : null; + const height = imageHeight ? parseInt(imageHeight) : null; + + editor.chain().focus().updateAttributes('image', { width, height }).run(); + + toast({ title: 'Berhasil', description: 'Ukuran gambar diperbarui' }); + }, [editor, selectedImage, imageWidth, imageHeight]); + + const setPresetSize = useCallback((size: 'small' | 'medium' | 'large' | 'full') => { + if (!editor) return; + + const sizes = { + small: { width: 200, height: null }, + medium: { width: 400, height: null }, + large: { width: 600, height: null }, + full: { width: null, height: null }, + }; + + const { width, height } = sizes[size]; + editor.chain().focus().updateAttributes('image', { width, height }).run(); + setImageWidth(width?.toString() || ''); + setImageHeight(height?.toString() || ''); + }, [editor]); + if (!editor) return null; return ( @@ -173,8 +300,8 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten. + + {/* Image resize popover */} + {selectedImage && ( + + + + + +
+

Ukuran Gambar

+
+ + + + +
+
+
+ + setImageWidth(e.target.value)} + placeholder="Auto" + className="h-8" + /> +
+
+ + setImageHeight(e.target.value)} + placeholder="Auto" + className="h-8" + /> +
+
+ +
+
+
+ )} +
+ + +
+ + + {/* Day headers */} +
+ {['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'].map(day => ( +
+ {day} +
+ ))} +
+ + {/* Calendar days */} +
+ {/* Empty cells for days before month start */} + {Array.from({ length: startOfMonth(currentMonth).getDay() }).map((_, i) => ( +
+ ))} + + {days.map(day => { + const dayEvents = getEventsForDate(day); + const isSelected = selectedDate && isSameDay(day, selectedDate); + const isToday = isSameDay(day, new Date()); + + return ( + + ); + })} +
+ + + + {/* Event Details */} + + + + {selectedDate + ? format(selectedDate, 'd MMMM yyyy', { locale: id }) + : 'Pilih Tanggal' + } + + + + {!selectedDate ? ( +

+ Klik tanggal untuk melihat event +

+ ) : selectedDateEvents.length === 0 ? ( +

+ Tidak ada event di tanggal ini +

+ ) : ( +
+ {selectedDateEvents.map(event => ( +
+
+ {getEventIcon(event.type)} + + {event.type} + +
+

{event.title}

+ {event.start_time && ( +

+ + {event.start_time} + {event.end_time && ` - ${event.end_time}`} +

+ )} +
+ ))} +
+ )} +
+
+
+ + + ); +} diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx new file mode 100644 index 0000000..f532473 --- /dev/null +++ b/src/pages/Privacy.tsx @@ -0,0 +1,89 @@ +import { AppLayout } from '@/components/AppLayout'; +import { useBranding } from '@/hooks/useBranding'; +import { Card, CardContent } from '@/components/ui/card'; + +export default function Privacy() { + const branding = useBranding(); + const platformName = branding.brand_name || 'LearnHub'; + + return ( + +
+

Kebijakan Privasi

+ + + +

+ Terakhir diperbarui: {new Date().toLocaleDateString('id-ID')} +

+ +

1. Informasi yang Kami Kumpulkan

+

+ {platformName} mengumpulkan informasi yang Anda berikan secara langsung, termasuk: +

+
    +
  • Nama lengkap dan alamat email saat pendaftaran akun
  • +
  • Informasi pembayaran untuk pembelian produk
  • +
  • Data progres pembelajaran untuk bootcamp
  • +
  • Catatan dan preferensi konsultasi
  • +
+ +

2. Penggunaan Informasi

+

Kami menggunakan informasi Anda untuk:

+
    +
  • Menyediakan dan mengelola akun Anda
  • +
  • Memproses transaksi dan mengirim notifikasi terkait
  • +
  • Menyediakan akses ke konten yang telah dibeli
  • +
  • Mengirim pembaruan tentang produk dan layanan kami
  • +
  • Menjawab pertanyaan dan memberikan dukungan pelanggan
  • +
+ +

3. Keamanan Data

+

+ Kami menerapkan langkah-langkah keamanan teknis dan organisasi untuk melindungi + informasi pribadi Anda dari akses tidak sah, penggunaan yang salah, atau pengungkapan. +

+ +

4. Berbagi Informasi

+

+ Kami tidak menjual atau menyewakan informasi pribadi Anda kepada pihak ketiga. + Kami hanya berbagi informasi dengan: +

+
    +
  • Penyedia layanan pembayaran untuk memproses transaksi
  • +
  • Penyedia layanan email untuk mengirim notifikasi
  • +
  • Otoritas hukum jika diwajibkan oleh hukum
  • +
+ +

5. Cookie dan Teknologi Pelacakan

+

+ Kami menggunakan cookie dan teknologi serupa untuk meningkatkan pengalaman pengguna, + menganalisis penggunaan situs, dan menyediakan konten yang relevan. +

+ +

6. Hak Anda

+

Anda memiliki hak untuk:

+
    +
  • Mengakses dan memperbarui informasi pribadi Anda
  • +
  • Meminta penghapusan akun dan data Anda
  • +
  • Menolak menerima email pemasaran
  • +
  • Mengajukan keluhan tentang penggunaan data Anda
  • +
+ +

7. Perubahan Kebijakan

+

+ Kami dapat memperbarui kebijakan privasi ini dari waktu ke waktu. + Perubahan signifikan akan diberitahukan melalui email atau notifikasi di platform. +

+ +

8. Hubungi Kami

+

+ Jika Anda memiliki pertanyaan tentang kebijakan privasi ini, silakan hubungi kami + melalui email atau fitur dukungan yang tersedia di platform. +

+
+
+
+
+ ); +} diff --git a/src/pages/Terms.tsx b/src/pages/Terms.tsx new file mode 100644 index 0000000..9908c66 --- /dev/null +++ b/src/pages/Terms.tsx @@ -0,0 +1,115 @@ +import { AppLayout } from '@/components/AppLayout'; +import { useBranding } from '@/hooks/useBranding'; +import { Card, CardContent } from '@/components/ui/card'; + +export default function Terms() { + const branding = useBranding(); + const platformName = branding.brand_name || 'LearnHub'; + + return ( + +
+

Syarat & Ketentuan

+ + + +

+ Terakhir diperbarui: {new Date().toLocaleDateString('id-ID')} +

+ +

1. Penerimaan Syarat

+

+ Dengan mengakses dan menggunakan {platformName}, Anda menyetujui untuk terikat + dengan syarat dan ketentuan ini. Jika Anda tidak setuju dengan bagian apapun + dari syarat ini, Anda tidak diperbolehkan menggunakan layanan kami. +

+ +

2. Akun Pengguna

+
    +
  • Anda bertanggung jawab untuk menjaga kerahasiaan kredensial akun Anda
  • +
  • Anda harus memberikan informasi yang akurat dan lengkap saat mendaftar
  • +
  • Anda bertanggung jawab atas semua aktivitas yang terjadi di akun Anda
  • +
  • Anda harus segera memberitahu kami jika ada penggunaan tidak sah
  • +
+ +

3. Pembelian dan Pembayaran

+
    +
  • Semua harga ditampilkan dalam Rupiah Indonesia (IDR)
  • +
  • Pembayaran diproses melalui gateway pembayaran pihak ketiga yang aman
  • +
  • Akses ke produk diberikan setelah pembayaran dikonfirmasi
  • +
  • Kami berhak mengubah harga produk tanpa pemberitahuan sebelumnya
  • +
+ +

4. Kebijakan Pengembalian Dana

+

+ Karena sifat produk digital kami, pengembalian dana umumnya tidak tersedia + setelah akses diberikan. Namun, kami dapat mempertimbangkan pengembalian dana + dalam kasus-kasus berikut: +

+
    +
  • Produk tidak sesuai dengan deskripsi secara signifikan
  • +
  • Masalah teknis yang tidak dapat diselesaikan
  • +
  • Permintaan dalam 7 hari pertama dengan penggunaan minimal
  • +
+ +

5. Hak Kekayaan Intelektual

+

+ Semua konten di {platformName}, termasuk teks, gambar, video, dan materi kursus, + dilindungi oleh hak cipta. Anda tidak diperbolehkan untuk: +

+
    +
  • Menyalin, mendistribusikan, atau menjual ulang konten kami
  • +
  • Membagikan akses akun dengan orang lain
  • +
  • Merekam atau mengunduh konten tanpa izin
  • +
  • Menggunakan konten untuk tujuan komersial tanpa lisensi
  • +
+ +

6. Layanan Konsultasi

+
    +
  • Jadwal konsultasi harus dipesan minimal 24 jam sebelumnya
  • +
  • Pembatalan harus dilakukan minimal 12 jam sebelum jadwal
  • +
  • Ketidakhadiran tanpa pemberitahuan dapat mengakibatkan kehilangan sesi
  • +
  • Konsultasi dilakukan melalui platform video yang ditentukan
  • +
+ +

7. Perilaku Pengguna

+

Anda setuju untuk tidak:

+
    +
  • Menggunakan layanan untuk tujuan ilegal
  • +
  • Mengganggu atau melecehkan pengguna lain
  • +
  • Menyebarkan malware atau konten berbahaya
  • +
  • Mencoba mengakses sistem kami secara tidak sah
  • +
+ +

8. Batasan Tanggung Jawab

+

+ {platformName} tidak bertanggung jawab atas kerugian tidak langsung, insidental, + atau konsekuensial yang timbul dari penggunaan layanan kami. Tanggung jawab + maksimum kami terbatas pada jumlah yang Anda bayarkan untuk produk terkait. +

+ +

9. Perubahan Syarat

+

+ Kami berhak memperbarui syarat dan ketentuan ini kapan saja. + Perubahan akan efektif segera setelah dipublikasikan di situs. + Penggunaan berkelanjutan setelah perubahan berarti penerimaan syarat baru. +

+ +

10. Hukum yang Berlaku

+

+ Syarat dan ketentuan ini diatur oleh hukum Republik Indonesia. + Setiap sengketa akan diselesaikan melalui mediasi atau pengadilan + yang berwenang di Indonesia. +

+ +

11. Hubungi Kami

+

+ Untuk pertanyaan tentang syarat dan ketentuan ini, silakan hubungi + tim dukungan kami melalui email atau fitur bantuan di platform. +

+
+
+
+
+ ); +} diff --git a/src/pages/member/OrderDetail.tsx b/src/pages/member/OrderDetail.tsx index c0f45e1..b77d77c 100644 --- a/src/pages/member/OrderDetail.tsx +++ b/src/pages/member/OrderDetail.tsx @@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; import { formatIDR, formatDate } from "@/lib/format"; -import { ArrowLeft, Package, CreditCard, Calendar, Clock } from "lucide-react"; +import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle } from "lucide-react"; interface OrderItem { id: string; @@ -42,36 +42,64 @@ export default function OrderDetail() { const navigate = useNavigate(); const [order, setOrder] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { - if (!authLoading && !user) navigate("/auth"); - else if (user && id) fetchOrder(); - }, [user, authLoading, id]); - - const fetchOrder = async () => { - const { data, error } = await supabase - .from("orders") - .select(` - *, - order_items ( - id, - product_id, - quantity, - price, - products (title, type, slug) - ) - `) - .eq("id", id) - .eq("user_id", user!.id) - .single(); - - if (error || !data) { - navigate("/orders"); + if (authLoading) return; + + if (!user) { + navigate("/auth"); return; } - setOrder(data as Order); - setLoading(false); + if (id) { + fetchOrder(); + } + }, [user, authLoading, id]); + + const fetchOrder = async () => { + if (!user || !id) return; + + setLoading(true); + setError(null); + + try { + const { data, error: queryError } = await supabase + .from("orders") + .select(` + *, + order_items ( + id, + product_id, + quantity, + price, + products (title, type, slug) + ) + `) + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (queryError) { + console.error("Order fetch error:", queryError); + setError("Gagal mengambil data order"); + setLoading(false); + return; + } + + if (!data) { + setError("Order tidak ditemukan"); + setLoading(false); + return; + } + + setOrder(data as Order); + } catch (err) { + console.error("Unexpected error:", err); + setError("Terjadi kesalahan"); + } finally { + setLoading(false); + } }; const getStatusColor = (status: string) => { @@ -127,6 +155,33 @@ export default function OrderDetail() { ); } + if (error) { + return ( + +
+ + + + +

Error

+

{error}

+ +
+
+
+
+ ); + } + if (!order) return null; return ( diff --git a/supabase/config.toml b/supabase/config.toml index 0aef8c0..a6c232c 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -26,3 +26,9 @@ verify_jwt = true [functions.send-consultation-reminder] verify_jwt = false + +[functions.send-notification] +verify_jwt = false + +[functions.daily-reminders] +verify_jwt = false diff --git a/supabase/functions/daily-reminders/index.ts b/supabase/functions/daily-reminders/index.ts new file mode 100644 index 0000000..8d49452 --- /dev/null +++ b/supabase/functions/daily-reminders/index.ts @@ -0,0 +1,138 @@ +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 ConsultingSlot { + id: string; + date: string; + start_time: string; + end_time: string; + topic_category: string; + meet_link: string | null; + user_id: string; + profiles: { + full_name: string; + email: string; + }; +} + +serve(async (req: Request): Promise => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const supabase = createClient(supabaseUrl, supabaseKey); + + // Get tomorrow's date + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowStr = tomorrow.toISOString().split('T')[0]; + + console.log(`Checking consultations for: ${tomorrowStr}`); + + // Get confirmed consulting slots for tomorrow + const { data: slots, error } = await supabase + .from("consulting_slots") + .select(` + id, + date, + start_time, + end_time, + topic_category, + meet_link, + user_id, + profiles:user_id (full_name, email) + `) + .eq("date", tomorrowStr) + .eq("status", "confirmed"); + + if (error) { + console.error("Error fetching slots:", error); + throw error; + } + + console.log(`Found ${slots?.length || 0} consultations for tomorrow`); + + const results: { email: string; success: boolean; error?: string }[] = []; + + // Send reminder to each client + for (const slot of (slots || []) as any[]) { + try { + const profile = slot.profiles; + if (!profile?.email) continue; + + // Call send-notification function + const { error: notifyError } = await supabase.functions.invoke("send-notification", { + body: { + template_key: "consultation_reminder", + recipient_email: profile.email, + recipient_name: profile.full_name, + variables: { + consultation_date: new Date(slot.date).toLocaleDateString('id-ID', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }), + consultation_time: `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)}`, + topic_category: slot.topic_category, + meet_link: slot.meet_link || "Link akan dikirim segera", + }, + }, + }); + + results.push({ + email: profile.email, + success: !notifyError, + error: notifyError?.message, + }); + + console.log(`Reminder sent to ${profile.email}: ${notifyError ? 'FAILED' : 'SUCCESS'}`); + } catch (err: any) { + results.push({ + email: slot.profiles?.email || "unknown", + success: false, + error: err.message, + }); + } + } + + // Get platform settings for admin digest + const { data: settings } = await supabase + .from("platform_settings") + .select("smtp_from_email") + .single(); + + // Send digest to admin if there are consultations + if (slots && slots.length > 0 && settings?.smtp_from_email) { + const slotsList = (slots as any[]) + .map(s => `- ${s.start_time?.substring(0, 5)}: ${s.profiles?.full_name || 'N/A'} (${s.topic_category})`) + .join('\n'); + + console.log(`Admin digest: ${slots.length} consultations tomorrow`); + // Could send admin digest here + } + + return new Response( + JSON.stringify({ + success: true, + message: `Processed ${results.length} reminders`, + results + }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error: any) { + console.error("Error in daily reminders:", error); + return new Response( + JSON.stringify({ success: false, message: error.message }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); diff --git a/supabase/functions/send-notification/index.ts b/supabase/functions/send-notification/index.ts new file mode 100644 index 0000000..488291c --- /dev/null +++ b/supabase/functions/send-notification/index.ts @@ -0,0 +1,327 @@ +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 NotificationRequest { + template_key: string; + recipient_email: string; + recipient_name?: string; + variables?: Record; +} + +interface SMTPConfig { + host: string; + port: number; + username: string; + password: string; + from_name: string; + from_email: string; + use_tls: boolean; +} + +interface EmailPayload { + to: string; + subject: string; + html: string; + from_name: string; + from_email: string; +} + +// Send via SMTP +async function sendViaSMTP(payload: EmailPayload, config: SMTPConfig): Promise { + const boundary = "----=_Part_" + Math.random().toString(36).substr(2, 9); + const emailContent = [ + `From: "${payload.from_name}" <${payload.from_email}>`, + `To: ${payload.to}`, + `Subject: =?UTF-8?B?${btoa(payload.subject)}?=`, + `MIME-Version: 1.0`, + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ``, + `--${boundary}`, + `Content-Type: text/html; charset=UTF-8`, + ``, + payload.html, + `--${boundary}--`, + ].join("\r\n"); + + const conn = config.use_tls + ? await Deno.connectTls({ hostname: config.host, port: config.port }) + : await Deno.connect({ hostname: config.host, port: config.port }); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + async function readResponse(): Promise { + const buffer = new Uint8Array(1024); + const n = await conn.read(buffer); + if (n === null) return ""; + return decoder.decode(buffer.subarray(0, n)); + } + + async function sendCommand(cmd: string): Promise { + await conn.write(encoder.encode(cmd + "\r\n")); + return await readResponse(); + } + + try { + await readResponse(); + await sendCommand(`EHLO localhost`); + await sendCommand("AUTH LOGIN"); + await sendCommand(btoa(config.username)); + const authResponse = await sendCommand(btoa(config.password)); + + if (!authResponse.includes("235") && !authResponse.includes("Authentication successful")) { + throw new Error("SMTP Authentication failed"); + } + + await sendCommand(`MAIL FROM:<${payload.from_email}>`); + await sendCommand(`RCPT TO:<${payload.to}>`); + await sendCommand("DATA"); + await conn.write(encoder.encode(emailContent + "\r\n.\r\n")); + await readResponse(); + await sendCommand("QUIT"); + conn.close(); + } catch (error) { + conn.close(); + throw error; + } +} + +// Send via Resend +async function sendViaResend(payload: EmailPayload, apiKey: string): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: `${payload.from_name} <${payload.from_email}>`, + to: [payload.to], + subject: payload.subject, + html: payload.html, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Resend error: ${error}`); + } +} + +// Send via ElasticEmail +async function sendViaElasticEmail(payload: EmailPayload, apiKey: string): Promise { + const response = await fetch("https://api.elasticemail.com/v4/emails/transactional", { + method: "POST", + headers: { + "X-ElasticEmail-ApiKey": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + Recipients: { + To: [payload.to], + }, + Content: { + From: `${payload.from_name} <${payload.from_email}>`, + Subject: payload.subject, + Body: [ + { + ContentType: "HTML", + Content: payload.html, + }, + ], + }, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`ElasticEmail error: ${error}`); + } +} + +// Send via SendGrid +async function sendViaSendGrid(payload: EmailPayload, apiKey: string): Promise { + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: payload.to }] }], + from: { email: payload.from_email, name: payload.from_name }, + subject: payload.subject, + content: [{ type: "text/html", value: payload.html }], + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`SendGrid error: ${error}`); + } +} + +// Send via Mailgun +async function sendViaMailgun(payload: EmailPayload, apiKey: string, domain: string): Promise { + const formData = new FormData(); + formData.append("from", `${payload.from_name} <${payload.from_email}>`); + formData.append("to", payload.to); + formData.append("subject", payload.subject); + formData.append("html", payload.html); + + const response = await fetch(`https://api.mailgun.net/v3/${domain}/messages`, { + method: "POST", + headers: { + "Authorization": `Basic ${btoa(`api:${apiKey}`)}`, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Mailgun error: ${error}`); + } +} + +function replaceVariables(template: string, variables: Record): string { + let result = template; + for (const [key, value] of Object.entries(variables)) { + result = result.replace(new RegExp(`{{${key}}}`, 'g'), value); + } + return result; +} + +serve(async (req: Request): Promise => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const supabase = createClient(supabaseUrl, supabaseKey); + + const body: NotificationRequest = await req.json(); + const { template_key, recipient_email, recipient_name, variables = {} } = body; + + // Get notification template + const { data: template, error: templateError } = await supabase + .from("notification_templates") + .select("*") + .eq("template_key", template_key) + .eq("is_active", true) + .single(); + + if (templateError || !template) { + console.log(`Template not found: ${template_key}`); + return new Response( + JSON.stringify({ success: false, message: "Template not found or inactive" }), + { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Get platform settings + const { data: settings } = await supabase + .from("platform_settings") + .select("*") + .single(); + + if (!settings) { + return new Response( + JSON.stringify({ success: false, message: "Platform settings not configured" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Build email payload + const allVariables = { + recipient_name: recipient_name || "Pelanggan", + platform_name: settings.brand_name || "Platform", + ...variables, + }; + + const subject = replaceVariables(template.subject, allVariables); + const htmlBody = replaceVariables(template.body_html || template.body_text || "", allVariables); + + const emailPayload: EmailPayload = { + to: recipient_email, + subject, + html: htmlBody, + from_name: settings.brand_email_from_name || settings.brand_name || "Notifikasi", + from_email: settings.smtp_from_email || "noreply@example.com", + }; + + // Determine provider and send + const provider = settings.integration_email_provider || "smtp"; + console.log(`Sending email via ${provider} to ${recipient_email}`); + + switch (provider) { + case "smtp": + await sendViaSMTP(emailPayload, { + host: settings.smtp_host, + port: settings.smtp_port || 587, + username: settings.smtp_username, + password: settings.smtp_password, + from_name: emailPayload.from_name, + from_email: emailPayload.from_email, + use_tls: settings.smtp_use_tls ?? true, + }); + break; + + case "resend": + const resendKey = Deno.env.get("RESEND_API_KEY"); + if (!resendKey) throw new Error("RESEND_API_KEY not configured"); + await sendViaResend(emailPayload, resendKey); + break; + + case "elasticemail": + const elasticKey = Deno.env.get("ELASTICEMAIL_API_KEY"); + if (!elasticKey) throw new Error("ELASTICEMAIL_API_KEY not configured"); + await sendViaElasticEmail(emailPayload, elasticKey); + break; + + case "sendgrid": + const sendgridKey = Deno.env.get("SENDGRID_API_KEY"); + if (!sendgridKey) throw new Error("SENDGRID_API_KEY not configured"); + await sendViaSendGrid(emailPayload, sendgridKey); + break; + + case "mailgun": + const mailgunKey = Deno.env.get("MAILGUN_API_KEY"); + const mailgunDomain = Deno.env.get("MAILGUN_DOMAIN"); + if (!mailgunKey || !mailgunDomain) throw new Error("MAILGUN credentials not configured"); + await sendViaMailgun(emailPayload, mailgunKey, mailgunDomain); + break; + + default: + throw new Error(`Unknown email provider: ${provider}`); + } + + // Log notification + await supabase.from("notification_logs").insert({ + template_id: template.id, + recipient_email, + channel: "email", + status: "sent", + sent_at: new Date().toISOString(), + }); + + console.log(`Email sent successfully to ${recipient_email}`); + + return new Response( + JSON.stringify({ success: true, message: "Notification sent" }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error: any) { + console.error("Error sending notification:", error); + return new Response( + JSON.stringify({ success: false, message: error.message }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +});