Changes
This commit is contained in:
12
package-lock.json
generated
12
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 = () => (
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
||||
<Route path="/consulting" element={<ConsultingBooking />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
|
||||
{/* Member routes */}
|
||||
<Route path="/dashboard" element={<MemberDashboard />} />
|
||||
|
||||
@@ -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<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 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.
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<label>
|
||||
<Button type="button" variant="ghost" size="sm" asChild>
|
||||
<span>
|
||||
<Button type="button" variant="ghost" size="sm" asChild disabled={uploading}>
|
||||
<span className={uploading ? 'opacity-50' : ''}>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</span>
|
||||
</Button>
|
||||
@@ -183,8 +310,57 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</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" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -208,9 +384,14 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
<div onPaste={handlePaste}>
|
||||
<EditorContent
|
||||
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>
|
||||
{uploading && (
|
||||
<div className="p-2 text-sm text-muted-foreground text-center border-t border-border">
|
||||
Mengunggah gambar...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@ export function IntegrasiTab() {
|
||||
<SelectContent>
|
||||
<SelectItem value="smtp">SMTP (Default)</SelectItem>
|
||||
<SelectItem value="resend">Resend</SelectItem>
|
||||
<SelectItem value="elasticemail">ElasticEmail</SelectItem>
|
||||
<SelectItem value="mailgun">Mailgun</SelectItem>
|
||||
<SelectItem value="sendgrid">SendGrid</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
303
src/pages/Calendar.tsx
Normal file
303
src/pages/Calendar.tsx
Normal 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
89
src/pages/Privacy.tsx
Normal 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
115
src/pages/Terms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,14 +42,29 @@ export default function OrderDetail() {
|
||||
const navigate = useNavigate();
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate("/auth");
|
||||
else if (user && id) fetchOrder();
|
||||
if (authLoading) return;
|
||||
|
||||
if (!user) {
|
||||
navigate("/auth");
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
fetchOrder();
|
||||
}
|
||||
}, [user, authLoading, id]);
|
||||
|
||||
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")
|
||||
.select(`
|
||||
*,
|
||||
@@ -62,16 +77,29 @@ export default function OrderDetail() {
|
||||
)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.eq("user_id", user!.id)
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
navigate("/orders");
|
||||
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 (
|
||||
<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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
|
||||
138
supabase/functions/daily-reminders/index.ts
Normal file
138
supabase/functions/daily-reminders/index.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
327
supabase/functions/send-notification/index.ts
Normal file
327
supabase/functions/send-notification/index.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user