Enhance rich text image handling

Improve RichTextEditor to upload images to storage with optional base64 fallback, add image size controls, fix order detail navigation, and implement frontend hooks for branding and admin tasks. Also address member order detail routing issue and prep for Google Meet/ElasticEmail integration.

X-Lovable-Edit-ID: edt-a7826be1-5123-430e-a6f9-a3d1aa3f13de
This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 16:02:32 +00:00
12 changed files with 1282 additions and 48 deletions

12
package-lock.json generated
View File

@@ -62,6 +62,7 @@
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiptap-extension-resize-image": "^1.3.2",
"vaul": "^0.9.9", "vaul": "^0.9.9",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
@@ -7235,6 +7236,17 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@@ -65,6 +65,7 @@
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiptap-extension-resize-image": "^1.3.2",
"vaul": "^0.9.9", "vaul": "^0.9.9",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },

View File

@@ -14,6 +14,9 @@ import Checkout from "./pages/Checkout";
import Bootcamp from "./pages/Bootcamp"; import Bootcamp from "./pages/Bootcamp";
import Events from "./pages/Events"; import Events from "./pages/Events";
import ConsultingBooking from "./pages/ConsultingBooking"; 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"; import NotFound from "./pages/NotFound";
// Member pages // Member pages
@@ -53,6 +56,9 @@ const App = () => (
<Route path="/events" element={<Events />} /> <Route path="/events" element={<Events />} />
<Route path="/bootcamp/:slug" element={<Bootcamp />} /> <Route path="/bootcamp/:slug" element={<Bootcamp />} />
<Route path="/consulting" element={<ConsultingBooking />} /> <Route path="/consulting" element={<ConsultingBooking />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
{/* Member routes */} {/* Member routes */}
<Route path="/dashboard" element={<MemberDashboard />} /> <Route path="/dashboard" element={<MemberDashboard />} />

View File

@@ -6,12 +6,16 @@ import Placeholder from '@tiptap/extension-placeholder';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon, 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'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { toast } from '@/hooks/use-toast'; 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 { interface RichTextEditorProps {
content: string; content: string;
@@ -20,7 +24,37 @@ interface RichTextEditorProps {
className?: string; 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) { 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({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
@@ -30,9 +64,9 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
class: 'text-primary underline', class: 'text-primary underline',
}, },
}), }),
Image.configure({ ResizableImage.configure({
HTMLAttributes: { HTMLAttributes: {
class: 'max-w-full h-auto rounded-md', class: 'max-w-full h-auto rounded-md cursor-pointer',
}, },
}), }),
Placeholder.configure({ Placeholder.configure({
@@ -43,9 +77,25 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
onChange(editor.getHTML()); 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(() => { useEffect(() => {
if (editor && content !== editor.getHTML()) { if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content || ''); editor.commands.setContent(content || '');
@@ -60,18 +110,65 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
} }
}, [editor]); }, [editor]);
const uploadImageToStorage = async (file: File): Promise<string | null> => {
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<string> => {
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<HTMLInputElement>) => { const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file || !editor) return; if (!file || !editor) return;
// For now, convert to base64 data URL since storage bucket may not be configured setUploading(true);
// In production, you would upload to Supabase Storage
const reader = new FileReader(); // Try to upload to storage first
reader.onload = () => { let imageUrl = await uploadImageToStorage(file);
const dataUrl = reader.result as string;
editor.chain().focus().setImage({ src: dataUrl }).run(); // Fall back to base64 if storage upload fails
}; if (!imageUrl) {
reader.readAsDataURL(file); 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]); }, [editor]);
const handlePaste = useCallback(async (e: React.ClipboardEvent) => { const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
@@ -84,17 +181,47 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
const file = item.getAsFile(); const file = item.getAsFile();
if (!file) continue; if (!file) continue;
const reader = new FileReader(); setUploading(true);
reader.onload = () => {
const dataUrl = reader.result as string; let imageUrl = await uploadImageToStorage(file);
editor.chain().focus().setImage({ src: dataUrl }).run(); if (!imageUrl) {
}; imageUrl = await convertToDataUrl(file);
reader.readAsDataURL(file); }
editor.chain().focus().setImage({ src: imageUrl }).run();
setUploading(false);
break; break;
} }
} }
}, [editor]); }, [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; if (!editor) return null;
return ( return (
@@ -173,8 +300,8 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
<LinkIcon className="w-4 h-4" /> <LinkIcon className="w-4 h-4" />
</Button> </Button>
<label> <label>
<Button type="button" variant="ghost" size="sm" asChild> <Button type="button" variant="ghost" size="sm" asChild disabled={uploading}>
<span> <span className={uploading ? 'opacity-50' : ''}>
<ImageIcon className="w-4 h-4" /> <ImageIcon className="w-4 h-4" />
</span> </span>
</Button> </Button>
@@ -183,8 +310,57 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
accept="image/*" accept="image/*"
onChange={handleImageUpload} onChange={handleImageUpload}
className="hidden" className="hidden"
disabled={uploading}
/> />
</label> </label>
{/* Image resize popover */}
{selectedImage && (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="bg-accent">
<Maximize2 className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64">
<div className="space-y-4">
<h4 className="font-medium">Ukuran Gambar</h4>
<div className="grid grid-cols-4 gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('small')}>S</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('medium')}>M</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('large')}>L</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('full')}>Full</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">Lebar (px)</Label>
<Input
type="number"
value={imageWidth}
onChange={(e) => setImageWidth(e.target.value)}
placeholder="Auto"
className="h-8"
/>
</div>
<div>
<Label className="text-xs">Tinggi (px)</Label>
<Input
type="number"
value={imageHeight}
onChange={(e) => setImageHeight(e.target.value)}
placeholder="Auto"
className="h-8"
/>
</div>
</div>
<Button type="button" size="sm" onClick={updateImageSize} className="w-full">
Terapkan
</Button>
</div>
</PopoverContent>
</Popover>
)}
<div className="flex-1" /> <div className="flex-1" />
<Button <Button
type="button" type="button"
@@ -208,9 +384,14 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
<div onPaste={handlePaste}> <div onPaste={handlePaste}>
<EditorContent <EditorContent
editor={editor} editor={editor}
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px]" className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary"
/> />
</div> </div>
{uploading && (
<div className="p-2 text-sm text-muted-foreground text-center border-t border-border">
Mengunggah gambar...
</div>
)}
</div> </div>
); );
} }

View File

@@ -213,6 +213,7 @@ export function IntegrasiTab() {
<SelectContent> <SelectContent>
<SelectItem value="smtp">SMTP (Default)</SelectItem> <SelectItem value="smtp">SMTP (Default)</SelectItem>
<SelectItem value="resend">Resend</SelectItem> <SelectItem value="resend">Resend</SelectItem>
<SelectItem value="elasticemail">ElasticEmail</SelectItem>
<SelectItem value="mailgun">Mailgun</SelectItem> <SelectItem value="mailgun">Mailgun</SelectItem>
<SelectItem value="sendgrid">SendGrid</SelectItem> <SelectItem value="sendgrid">SendGrid</SelectItem>
</SelectContent> </SelectContent>

303
src/pages/Calendar.tsx Normal file
View File

@@ -0,0 +1,303 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Calendar as CalendarIcon, Video, BookOpen, Users, Clock, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, parseISO } from 'date-fns';
import { id } from 'date-fns/locale';
import { cn } from '@/lib/utils';
interface CalendarEvent {
id: string;
title: string;
type: 'bootcamp' | 'webinar' | 'consulting';
date: string;
start_time?: string;
end_time?: string;
description?: string;
}
export default function Calendar() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
useEffect(() => {
fetchEvents();
}, [currentMonth]);
const fetchEvents = async () => {
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
const end = format(endOfMonth(currentMonth), 'yyyy-MM-dd');
// Fetch bootcamp events
const { data: bootcamps } = await supabase
.from('products')
.select('id, title, event_start')
.eq('type', 'bootcamp')
.eq('is_active', true)
.gte('event_start', start)
.lte('event_start', end);
// Fetch webinar events
const { data: webinars } = await supabase
.from('products')
.select('id, title, event_start, duration')
.eq('type', 'webinar')
.eq('is_active', true)
.gte('event_start', start)
.lte('event_start', end);
// Fetch confirmed consulting slots
const { data: consultings } = await supabase
.from('consulting_slots')
.select('id, date, start_time, end_time, topic_category')
.eq('status', 'confirmed')
.gte('date', start)
.lte('date', end);
const allEvents: CalendarEvent[] = [];
bootcamps?.forEach(b => {
if (b.event_start) {
allEvents.push({
id: b.id,
title: b.title,
type: 'bootcamp',
date: b.event_start.split('T')[0],
});
}
});
webinars?.forEach(w => {
if (w.event_start) {
const eventDate = new Date(w.event_start);
allEvents.push({
id: w.id,
title: w.title,
type: 'webinar',
date: format(eventDate, 'yyyy-MM-dd'),
start_time: format(eventDate, 'HH:mm'),
});
}
});
consultings?.forEach(c => {
allEvents.push({
id: c.id,
title: `Konsultasi: ${c.topic_category}`,
type: 'consulting',
date: c.date,
start_time: c.start_time?.substring(0, 5),
end_time: c.end_time?.substring(0, 5),
});
});
setEvents(allEvents);
setLoading(false);
};
const days = eachDayOfInterval({
start: startOfMonth(currentMonth),
end: endOfMonth(currentMonth),
});
const getEventsForDate = (date: Date) => {
return events.filter(e => isSameDay(parseISO(e.date), date));
};
const getEventIcon = (type: string) => {
switch (type) {
case 'bootcamp': return <BookOpen className="w-3 h-3" />;
case 'webinar': return <Video className="w-3 h-3" />;
case 'consulting': return <Users className="w-3 h-3" />;
default: return <CalendarIcon className="w-3 h-3" />;
}
};
const getEventColor = (type: string) => {
switch (type) {
case 'bootcamp': return 'bg-primary text-primary-foreground';
case 'webinar': return 'bg-accent text-primary';
case 'consulting': return 'bg-secondary text-secondary-foreground';
default: return 'bg-muted';
}
};
const selectedDateEvents = selectedDate ? getEventsForDate(selectedDate) : [];
if (loading) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<Skeleton className="h-96 w-full" />
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-2 flex items-center gap-3">
<CalendarIcon className="w-10 h-10" />
Kalender Event
</h1>
<p className="text-muted-foreground mb-8">
Lihat jadwal bootcamp, webinar, dan konsultasi
</p>
{/* Legend */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-primary rounded" />
<span className="text-sm">Bootcamp</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-accent rounded" />
<span className="text-sm">Webinar</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-secondary rounded" />
<span className="text-sm">Konsultasi</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Calendar Grid */}
<Card className="border-2 border-border lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
{format(currentMonth, 'MMMM yyyy', { locale: id })}
</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(new Date())}>
Hari Ini
</Button>
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'].map(day => (
<div key={day} className="text-center text-sm font-medium text-muted-foreground py-2">
{day}
</div>
))}
</div>
{/* Calendar days */}
<div className="grid grid-cols-7 gap-1">
{/* Empty cells for days before month start */}
{Array.from({ length: startOfMonth(currentMonth).getDay() }).map((_, i) => (
<div key={`empty-${i}`} className="aspect-square p-1" />
))}
{days.map(day => {
const dayEvents = getEventsForDate(day);
const isSelected = selectedDate && isSameDay(day, selectedDate);
const isToday = isSameDay(day, new Date());
return (
<button
key={day.toISOString()}
onClick={() => setSelectedDate(day)}
className={cn(
"aspect-square p-1 text-sm rounded-md transition-colors relative",
isSelected && "bg-primary text-primary-foreground",
!isSelected && isToday && "bg-accent",
!isSelected && !isToday && "hover:bg-muted",
!isSameMonth(day, currentMonth) && "text-muted-foreground opacity-50"
)}
>
<span className="block">{format(day, 'd')}</span>
{dayEvents.length > 0 && (
<div className="flex gap-0.5 justify-center mt-1">
{dayEvents.slice(0, 3).map((e, i) => (
<div
key={i}
className={cn(
"w-1.5 h-1.5 rounded-full",
e.type === 'bootcamp' && "bg-primary",
e.type === 'webinar' && "bg-accent",
e.type === 'consulting' && "bg-secondary"
)}
/>
))}
{dayEvents.length > 3 && (
<span className="text-[8px]">+{dayEvents.length - 3}</span>
)}
</div>
)}
</button>
);
})}
</div>
</CardContent>
</Card>
{/* Event Details */}
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-lg">
{selectedDate
? format(selectedDate, 'd MMMM yyyy', { locale: id })
: 'Pilih Tanggal'
}
</CardTitle>
</CardHeader>
<CardContent>
{!selectedDate ? (
<p className="text-muted-foreground text-sm">
Klik tanggal untuk melihat event
</p>
) : selectedDateEvents.length === 0 ? (
<p className="text-muted-foreground text-sm">
Tidak ada event di tanggal ini
</p>
) : (
<div className="space-y-3">
{selectedDateEvents.map(event => (
<div
key={event.id}
className={cn(
"p-3 rounded-md",
getEventColor(event.type)
)}
>
<div className="flex items-center gap-2 mb-1">
{getEventIcon(event.type)}
<Badge variant="outline" className="text-xs capitalize">
{event.type}
</Badge>
</div>
<p className="font-medium">{event.title}</p>
{event.start_time && (
<p className="text-sm flex items-center gap-1 mt-1">
<Clock className="w-3 h-3" />
{event.start_time}
{event.end_time && ` - ${event.end_time}`}
</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</AppLayout>
);
}

89
src/pages/Privacy.tsx Normal file
View File

@@ -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 (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-4xl">
<h1 className="text-4xl font-bold mb-8">Kebijakan Privasi</h1>
<Card className="border-2 border-border">
<CardContent className="prose max-w-none pt-6">
<p className="text-muted-foreground">
Terakhir diperbarui: {new Date().toLocaleDateString('id-ID')}
</p>
<h2>1. Informasi yang Kami Kumpulkan</h2>
<p>
{platformName} mengumpulkan informasi yang Anda berikan secara langsung, termasuk:
</p>
<ul>
<li>Nama lengkap dan alamat email saat pendaftaran akun</li>
<li>Informasi pembayaran untuk pembelian produk</li>
<li>Data progres pembelajaran untuk bootcamp</li>
<li>Catatan dan preferensi konsultasi</li>
</ul>
<h2>2. Penggunaan Informasi</h2>
<p>Kami menggunakan informasi Anda untuk:</p>
<ul>
<li>Menyediakan dan mengelola akun Anda</li>
<li>Memproses transaksi dan mengirim notifikasi terkait</li>
<li>Menyediakan akses ke konten yang telah dibeli</li>
<li>Mengirim pembaruan tentang produk dan layanan kami</li>
<li>Menjawab pertanyaan dan memberikan dukungan pelanggan</li>
</ul>
<h2>3. Keamanan Data</h2>
<p>
Kami menerapkan langkah-langkah keamanan teknis dan organisasi untuk melindungi
informasi pribadi Anda dari akses tidak sah, penggunaan yang salah, atau pengungkapan.
</p>
<h2>4. Berbagi Informasi</h2>
<p>
Kami tidak menjual atau menyewakan informasi pribadi Anda kepada pihak ketiga.
Kami hanya berbagi informasi dengan:
</p>
<ul>
<li>Penyedia layanan pembayaran untuk memproses transaksi</li>
<li>Penyedia layanan email untuk mengirim notifikasi</li>
<li>Otoritas hukum jika diwajibkan oleh hukum</li>
</ul>
<h2>5. Cookie dan Teknologi Pelacakan</h2>
<p>
Kami menggunakan cookie dan teknologi serupa untuk meningkatkan pengalaman pengguna,
menganalisis penggunaan situs, dan menyediakan konten yang relevan.
</p>
<h2>6. Hak Anda</h2>
<p>Anda memiliki hak untuk:</p>
<ul>
<li>Mengakses dan memperbarui informasi pribadi Anda</li>
<li>Meminta penghapusan akun dan data Anda</li>
<li>Menolak menerima email pemasaran</li>
<li>Mengajukan keluhan tentang penggunaan data Anda</li>
</ul>
<h2>7. Perubahan Kebijakan</h2>
<p>
Kami dapat memperbarui kebijakan privasi ini dari waktu ke waktu.
Perubahan signifikan akan diberitahukan melalui email atau notifikasi di platform.
</p>
<h2>8. Hubungi Kami</h2>
<p>
Jika Anda memiliki pertanyaan tentang kebijakan privasi ini, silakan hubungi kami
melalui email atau fitur dukungan yang tersedia di platform.
</p>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

115
src/pages/Terms.tsx Normal file
View File

@@ -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 (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-4xl">
<h1 className="text-4xl font-bold mb-8">Syarat & Ketentuan</h1>
<Card className="border-2 border-border">
<CardContent className="prose max-w-none pt-6">
<p className="text-muted-foreground">
Terakhir diperbarui: {new Date().toLocaleDateString('id-ID')}
</p>
<h2>1. Penerimaan Syarat</h2>
<p>
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.
</p>
<h2>2. Akun Pengguna</h2>
<ul>
<li>Anda bertanggung jawab untuk menjaga kerahasiaan kredensial akun Anda</li>
<li>Anda harus memberikan informasi yang akurat dan lengkap saat mendaftar</li>
<li>Anda bertanggung jawab atas semua aktivitas yang terjadi di akun Anda</li>
<li>Anda harus segera memberitahu kami jika ada penggunaan tidak sah</li>
</ul>
<h2>3. Pembelian dan Pembayaran</h2>
<ul>
<li>Semua harga ditampilkan dalam Rupiah Indonesia (IDR)</li>
<li>Pembayaran diproses melalui gateway pembayaran pihak ketiga yang aman</li>
<li>Akses ke produk diberikan setelah pembayaran dikonfirmasi</li>
<li>Kami berhak mengubah harga produk tanpa pemberitahuan sebelumnya</li>
</ul>
<h2>4. Kebijakan Pengembalian Dana</h2>
<p>
Karena sifat produk digital kami, pengembalian dana umumnya tidak tersedia
setelah akses diberikan. Namun, kami dapat mempertimbangkan pengembalian dana
dalam kasus-kasus berikut:
</p>
<ul>
<li>Produk tidak sesuai dengan deskripsi secara signifikan</li>
<li>Masalah teknis yang tidak dapat diselesaikan</li>
<li>Permintaan dalam 7 hari pertama dengan penggunaan minimal</li>
</ul>
<h2>5. Hak Kekayaan Intelektual</h2>
<p>
Semua konten di {platformName}, termasuk teks, gambar, video, dan materi kursus,
dilindungi oleh hak cipta. Anda tidak diperbolehkan untuk:
</p>
<ul>
<li>Menyalin, mendistribusikan, atau menjual ulang konten kami</li>
<li>Membagikan akses akun dengan orang lain</li>
<li>Merekam atau mengunduh konten tanpa izin</li>
<li>Menggunakan konten untuk tujuan komersial tanpa lisensi</li>
</ul>
<h2>6. Layanan Konsultasi</h2>
<ul>
<li>Jadwal konsultasi harus dipesan minimal 24 jam sebelumnya</li>
<li>Pembatalan harus dilakukan minimal 12 jam sebelum jadwal</li>
<li>Ketidakhadiran tanpa pemberitahuan dapat mengakibatkan kehilangan sesi</li>
<li>Konsultasi dilakukan melalui platform video yang ditentukan</li>
</ul>
<h2>7. Perilaku Pengguna</h2>
<p>Anda setuju untuk tidak:</p>
<ul>
<li>Menggunakan layanan untuk tujuan ilegal</li>
<li>Mengganggu atau melecehkan pengguna lain</li>
<li>Menyebarkan malware atau konten berbahaya</li>
<li>Mencoba mengakses sistem kami secara tidak sah</li>
</ul>
<h2>8. Batasan Tanggung Jawab</h2>
<p>
{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.
</p>
<h2>9. Perubahan Syarat</h2>
<p>
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.
</p>
<h2>10. Hukum yang Berlaku</h2>
<p>
Syarat dan ketentuan ini diatur oleh hukum Republik Indonesia.
Setiap sengketa akan diselesaikan melalui mediasi atau pengadilan
yang berwenang di Indonesia.
</p>
<h2>11. Hubungi Kami</h2>
<p>
Untuk pertanyaan tentang syarat dan ketentuan ini, silakan hubungi
tim dukungan kami melalui email atau fitur bantuan di platform.
</p>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { formatIDR, formatDate } from "@/lib/format"; 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 { interface OrderItem {
id: string; id: string;
@@ -42,14 +42,29 @@ export default function OrderDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null); const [order, setOrder] = useState<Order | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) navigate("/auth"); if (authLoading) return;
else if (user && id) fetchOrder();
if (!user) {
navigate("/auth");
return;
}
if (id) {
fetchOrder();
}
}, [user, authLoading, id]); }, [user, authLoading, id]);
const fetchOrder = async () => { const fetchOrder = async () => {
const { data, error } = await supabase if (!user || !id) return;
setLoading(true);
setError(null);
try {
const { data, error: queryError } = await supabase
.from("orders") .from("orders")
.select(` .select(`
*, *,
@@ -62,16 +77,29 @@ export default function OrderDetail() {
) )
`) `)
.eq("id", id) .eq("id", id)
.eq("user_id", user!.id) .eq("user_id", user.id)
.single(); .single();
if (error || !data) { if (queryError) {
navigate("/orders"); console.error("Order fetch error:", queryError);
setError("Gagal mengambil data order");
setLoading(false);
return;
}
if (!data) {
setError("Order tidak ditemukan");
setLoading(false);
return; return;
} }
setOrder(data as Order); setOrder(data as Order);
} catch (err) {
console.error("Unexpected error:", err);
setError("Terjadi kesalahan");
} finally {
setLoading(false); setLoading(false);
}
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -127,6 +155,33 @@ export default function OrderDetail() {
); );
} }
if (error) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-3xl">
<Button
variant="ghost"
onClick={() => navigate("/orders")}
className="mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Kembali ke Riwayat Order
</Button>
<Card className="border-2 border-destructive">
<CardContent className="py-8 text-center">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<h2 className="text-xl font-bold mb-2">Error</h2>
<p className="text-muted-foreground mb-4">{error}</p>
<Button onClick={() => navigate("/orders")}>
Kembali ke Riwayat Order
</Button>
</CardContent>
</Card>
</div>
</AppLayout>
);
}
if (!order) return null; if (!order) return null;
return ( return (

View File

@@ -26,3 +26,9 @@ verify_jwt = true
[functions.send-consultation-reminder] [functions.send-consultation-reminder]
verify_jwt = false verify_jwt = false
[functions.send-notification]
verify_jwt = false
[functions.daily-reminders]
verify_jwt = false

View File

@@ -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<Response> => {
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" } }
);
}
});

View File

@@ -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<string, string>;
}
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<void> {
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<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 {
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<void> {
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<void> {
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<void> {
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<void> {
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, string>): 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<Response> => {
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" } }
);
}
});