diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx
index 4ebd782..b353eeb 100644
--- a/src/components/AppLayout.tsx
+++ b/src/components/AppLayout.tsx
@@ -5,6 +5,7 @@ import { useCart } from '@/contexts/CartContext';
import { useBranding } from '@/hooks/useBranding';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
+import { Footer } from '@/components/Footer';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
@@ -101,7 +102,7 @@ export function AppLayout({ children }: AppLayoutProps) {
if (!user) {
// Public layout for non-authenticated pages
return (
-
+
@@ -113,7 +114,7 @@ export function AppLayout({ children }: AppLayoutProps) {
-
{children}
+
{children}
+
);
}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 0000000..907c7ea
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,67 @@
+import { Link } from 'react-router-dom';
+import { useBranding } from '@/hooks/useBranding';
+
+export function Footer() {
+ const branding = useBranding();
+ const brandName = branding.brand_name || 'LearnHub';
+ const currentYear = new Date().getFullYear();
+
+ return (
+
+ );
+}
diff --git a/src/pages/Bootcamp.tsx b/src/pages/Bootcamp.tsx
index 3e4028c..cb0adf0 100644
--- a/src/pages/Bootcamp.tsx
+++ b/src/pages/Bootcamp.tsx
@@ -137,7 +137,7 @@ export default function Bootcamp() {
};
const markAsCompleted = async () => {
- if (!selectedLesson || !user) return;
+ if (!selectedLesson || !user || !product) return;
const { error } = await supabase
.from('lesson_progress')
@@ -152,7 +152,34 @@ export default function Bootcamp() {
return;
}
- setProgress([...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }]);
+ const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }];
+ setProgress(newProgress);
+
+ // Calculate completion percentage for notification
+ const completedCount = newProgress.length;
+ const completionPercent = Math.round((completedCount / totalLessons) * 100);
+
+ // Trigger progress notification at milestones
+ if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) {
+ try {
+ await supabase.functions.invoke('send-notification', {
+ body: {
+ template_key: 'bootcamp_progress',
+ recipient_email: user.email,
+ recipient_name: user.user_metadata?.name || 'Peserta',
+ variables: {
+ bootcamp_title: product.title,
+ progress_percent: completionPercent.toString(),
+ completed_lessons: completedCount.toString(),
+ total_lessons: totalLessons.toString(),
+ },
+ },
+ });
+ } catch (err) {
+ console.log('Progress notification skipped:', err);
+ }
+ }
+
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
goToNextLesson();
};
diff --git a/src/pages/admin/AdminConsulting.tsx b/src/pages/admin/AdminConsulting.tsx
index f9326b2..9aadd68 100644
--- a/src/pages/admin/AdminConsulting.tsx
+++ b/src/pages/admin/AdminConsulting.tsx
@@ -10,10 +10,12 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast';
import { formatIDR } from '@/lib/format';
-import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink } from 'lucide-react';
-import { format, parseISO } from 'date-fns';
+import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink, CheckCircle, XCircle, Loader2 } from 'lucide-react';
+import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns';
import { id } from 'date-fns/locale';
interface ConsultingSlot {
@@ -34,6 +36,11 @@ interface ConsultingSlot {
};
}
+interface PlatformSettings {
+ integration_n8n_base_url?: string;
+ integration_google_calendar_id?: string;
+}
+
const statusLabels: Record
= {
pending_payment: { label: 'Menunggu Pembayaran', variant: 'secondary' },
confirmed: { label: 'Dikonfirmasi', variant: 'default' },
@@ -46,17 +53,23 @@ export default function AdminConsulting() {
const navigate = useNavigate();
const [slots, setSlots] = useState([]);
+ const [settings, setSettings] = useState({});
const [loading, setLoading] = useState(true);
const [selectedSlot, setSelectedSlot] = useState(null);
const [meetLink, setMeetLink] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
+ const [creatingMeet, setCreatingMeet] = useState(false);
+ const [activeTab, setActiveTab] = useState('upcoming');
useEffect(() => {
if (!authLoading) {
if (!user) navigate('/auth');
else if (!isAdmin) navigate('/dashboard');
- else fetchSlots();
+ else {
+ fetchSlots();
+ fetchSettings();
+ }
}
}, [user, isAdmin, authLoading]);
@@ -74,6 +87,15 @@ export default function AdminConsulting() {
setLoading(false);
};
+ const fetchSettings = async () => {
+ const { data } = await supabase
+ .from('platform_settings')
+ .select('integration_n8n_base_url, integration_google_calendar_id')
+ .single();
+
+ if (data) setSettings(data);
+ };
+
const openMeetDialog = (slot: ConsultingSlot) => {
setSelectedSlot(slot);
setMeetLink(slot.meet_link || '');
@@ -96,31 +118,98 @@ export default function AdminConsulting() {
setDialogOpen(false);
fetchSlots();
- // TODO: Trigger notification with meet link
- console.log('Would trigger consulting_scheduled notification with meet_link:', meetLink);
+ // Send notification to client with meet link
+ if (meetLink && selectedSlot.profiles?.email) {
+ try {
+ await supabase.functions.invoke('send-notification', {
+ body: {
+ template_key: 'consulting_scheduled',
+ recipient_email: selectedSlot.profiles.email,
+ recipient_name: selectedSlot.profiles.full_name,
+ variables: {
+ consultation_date: format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id }),
+ consultation_time: `${selectedSlot.start_time.substring(0, 5)} - ${selectedSlot.end_time.substring(0, 5)}`,
+ meet_link: meetLink,
+ topic_category: selectedSlot.topic_category,
+ },
+ },
+ });
+ } catch (err) {
+ console.log('Notification skipped:', err);
+ }
+ }
}
setSaving(false);
};
const createMeetLink = async () => {
- // Placeholder for Google Calendar API integration
- const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
+ if (!selectedSlot) return;
- if (!GOOGLE_CLIENT_ID) {
+ // Check if n8n webhook is configured
+ const webhookUrl = settings.integration_n8n_base_url;
+ if (!webhookUrl) {
toast({
title: 'Info',
- description: 'VITE_GOOGLE_CLIENT_ID belum dikonfigurasi. Masukkan link Meet secara manual.',
+ description: 'Webhook URL belum dikonfigurasi di Pengaturan Integrasi. Masukkan link Meet secara manual.',
});
return;
}
- // TODO: Implement actual Google Calendar API call
- // For now, log what would happen
- console.log('Would call Google Calendar API to create Meet link for slot:', selectedSlot);
- toast({
- title: 'Info',
- description: 'Integrasi Google Calendar akan tersedia setelah konfigurasi OAuth selesai.',
- });
+ setCreatingMeet(true);
+
+ try {
+ // Call the webhook to create Google Meet link
+ const response = await fetch(`${webhookUrl}/create-meet`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ slot_id: selectedSlot.id,
+ date: selectedSlot.date,
+ start_time: selectedSlot.start_time,
+ end_time: selectedSlot.end_time,
+ topic: selectedSlot.topic_category,
+ client_name: selectedSlot.profiles?.full_name || 'Client',
+ client_email: selectedSlot.profiles?.email,
+ calendar_id: settings.integration_google_calendar_id,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Webhook request failed');
+ }
+
+ const data = await response.json();
+
+ if (data.meet_link) {
+ setMeetLink(data.meet_link);
+ toast({ title: 'Berhasil', description: 'Link Google Meet dibuat' });
+ } else {
+ throw new Error('No meet_link in response');
+ }
+ } catch (error) {
+ console.error('Error creating meet link:', error);
+ toast({
+ title: 'Gagal',
+ description: 'Gagal membuat link Meet. Pastikan webhook sudah dikonfigurasi dengan benar.',
+ variant: 'destructive',
+ });
+ } finally {
+ setCreatingMeet(false);
+ }
+ };
+
+ const updateSlotStatus = async (slotId: string, newStatus: string) => {
+ const { error } = await supabase
+ .from('consulting_slots')
+ .update({ status: newStatus })
+ .eq('id', slotId);
+
+ if (error) {
+ toast({ title: 'Error', description: error.message, variant: 'destructive' });
+ } else {
+ toast({ title: 'Berhasil', description: `Status diubah ke ${statusLabels[newStatus]?.label || newStatus}` });
+ fetchSlots();
+ }
};
if (authLoading || loading) {
@@ -134,8 +223,10 @@ export default function AdminConsulting() {
);
}
- const confirmedSlots = slots.filter(s => s.status === 'confirmed');
- const pendingSlots = slots.filter(s => s.status === 'pending_payment');
+ const today = new Date().toISOString().split('T')[0];
+ const upcomingSlots = slots.filter(s => s.date >= today && (s.status === 'confirmed' || s.status === 'pending_payment'));
+ const pastSlots = slots.filter(s => s.date < today || s.status === 'completed' || s.status === 'cancelled');
+ const todaySlots = slots.filter(s => isToday(parseISO(s.date)) && s.status === 'confirmed');
return (
@@ -148,113 +239,214 @@ export default function AdminConsulting() {
Kelola jadwal dan link Google Meet untuk sesi konsultasi
+ {/* Today's Sessions Alert */}
+ {todaySlots.length > 0 && (
+
+
+
+
+ Sesi Hari Ini ({todaySlots.length})
+
+
+ {todaySlots.map(slot => (
+
+
+ {slot.start_time.substring(0, 5)} - {slot.profiles?.full_name || 'N/A'} ({slot.topic_category})
+
+ {slot.meet_link ? (
+
+ Join
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ )}
+
{/* Stats */}
-
+
- {confirmedSlots.length}
+ {todaySlots.length}
+ Hari Ini
+
+
+
+
+ {upcomingSlots.filter(s => s.status === 'confirmed').length}
Dikonfirmasi
- {pendingSlots.length}
- Menunggu Pembayaran
+ {upcomingSlots.filter(s => !s.meet_link && s.status === 'confirmed').length}
+ Perlu Link Meet
-
- {confirmedSlots.filter(s => !s.meet_link).length}
-
- Perlu Link Meet
+ {pastSlots.filter(s => s.status === 'completed').length}
+ Selesai
- {/* Slots Table */}
-
-
- Jadwal Konsultasi
- Daftar semua booking konsultasi
-
-
-
-
-
- Tanggal
- Waktu
- Klien
- Kategori
- Status
- Link Meet
- Aksi
-
-
-
- {slots.map((slot) => (
-
-
- {format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
-
-
- {slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
-
-
-
-
{slot.profiles?.full_name || '-'}
-
{slot.profiles?.email}
-
-
-
- {slot.topic_category}
-
-
-
- {statusLabels[slot.status]?.label || slot.status}
-
-
-
- {slot.meet_link ? (
-
-
- Buka
-
- ) : (
- -
- )}
-
-
- {slot.status === 'confirmed' && (
-
- )}
-
-
- ))}
- {slots.length === 0 && (
-
-
- Belum ada booking konsultasi
-
-
- )}
-
-
-
-
+ {/* Tabs */}
+
+
+ Mendatang ({upcomingSlots.length})
+ Riwayat ({pastSlots.length})
+
+
+
+
+
+
+
+
+ Tanggal
+ Waktu
+ Klien
+ Kategori
+ Status
+ Link Meet
+ Aksi
+
+
+
+ {upcomingSlots.map((slot) => (
+
+
+
+ {format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
+ {isToday(parseISO(slot.date)) && Hari Ini}
+ {isTomorrow(parseISO(slot.date)) && Besok}
+
+
+
+ {slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
+
+
+
+
{slot.profiles?.full_name || '-'}
+
{slot.profiles?.email}
+
+
+
+ {slot.topic_category}
+
+
+
+ {statusLabels[slot.status]?.label || slot.status}
+
+
+
+ {slot.meet_link ? (
+
+
+ Buka
+
+ ) : (
+ -
+ )}
+
+
+ {slot.status === 'confirmed' && (
+ <>
+
+
+
+ >
+ )}
+
+
+ ))}
+ {upcomingSlots.length === 0 && (
+
+
+ Tidak ada jadwal mendatang
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ Tanggal
+ Waktu
+ Klien
+ Kategori
+ Status
+
+
+
+ {pastSlots.slice(0, 20).map((slot) => (
+
+ {format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
+ {slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
+ {slot.profiles?.full_name || '-'}
+ {slot.topic_category}
+
+
+ {statusLabels[slot.status]?.label || slot.status}
+
+
+
+ ))}
+ {pastSlots.length === 0 && (
+
+
+ Belum ada riwayat konsultasi
+
+
+ )}
+
+
+
+
+
+
{/* Meet Link Dialog */}
-
+
+ {!settings.integration_n8n_base_url && (
+
+ Tip: Konfigurasi webhook di Pengaturan → Integrasi untuk pembuatan otomatis
+
+ )}