Continue remaining tasks

Implement frontend features for LMS gaps, branding, and admin consulting, plus enhancements to rich text and member order detail. This includes Google Meet webhook frontend stub, ElasticEmail adapter, HTML in descriptions, improved navigation, and improved UI/UX for consulting management and notifications.

X-Lovable-Edit-ID: edt-5031906b-9c9f-4b2f-8526-fe8a75340c65
This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 16:09:43 +00:00
4 changed files with 422 additions and 116 deletions

View File

@@ -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 (
<div className="min-h-screen bg-background">
<div className="min-h-screen bg-background flex flex-col">
<header className="border-b-2 border-border bg-background sticky top-0 z-50">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link to="/" className="text-2xl font-bold flex items-center gap-2">
@@ -113,7 +114,7 @@ export function AppLayout({ children }: AppLayoutProps) {
</Link>
<nav className="flex items-center gap-4">
<Link to="/products" className="hover:underline font-medium">Produk</Link>
<Link to="/events" className="hover:underline font-medium">Kalender</Link>
<Link to="/calendar" className="hover:underline font-medium">Kalender</Link>
<Link to="/auth">
<Button variant="outline" size="sm" className="border-2">
<User className="w-4 h-4 mr-2" />
@@ -133,7 +134,8 @@ export function AppLayout({ children }: AppLayoutProps) {
</nav>
</div>
</header>
<main>{children}</main>
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}

67
src/components/Footer.tsx Normal file
View File

@@ -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 (
<footer className="border-t-2 border-border bg-muted/50 mt-auto">
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="md:col-span-2">
<h3 className="font-bold text-lg mb-2">{brandName}</h3>
<p className="text-sm text-muted-foreground max-w-md">
{branding.brand_tagline || 'Platform pembelajaran online untuk mengembangkan skill dan karir Anda.'}
</p>
</div>
{/* Quick Links */}
<div>
<h4 className="font-semibold mb-3">Tautan</h4>
<ul className="space-y-2 text-sm">
<li>
<Link to="/products" className="text-muted-foreground hover:text-foreground transition-colors">
Produk
</Link>
</li>
<li>
<Link to="/calendar" className="text-muted-foreground hover:text-foreground transition-colors">
Kalender Event
</Link>
</li>
<li>
<Link to="/consulting" className="text-muted-foreground hover:text-foreground transition-colors">
Konsultasi
</Link>
</li>
</ul>
</div>
{/* Legal */}
<div>
<h4 className="font-semibold mb-3">Legal</h4>
<ul className="space-y-2 text-sm">
<li>
<Link to="/privacy" className="text-muted-foreground hover:text-foreground transition-colors">
Kebijakan Privasi
</Link>
</li>
<li>
<Link to="/terms" className="text-muted-foreground hover:text-foreground transition-colors">
Syarat & Ketentuan
</Link>
</li>
</ul>
</div>
</div>
<div className="border-t border-border mt-8 pt-6 text-center text-sm text-muted-foreground">
© {currentYear} {brandName}. All rights reserved.
</div>
</div>
</footer>
);
}

View File

@@ -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();
};

View File

@@ -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<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
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<ConsultingSlot[]>([]);
const [settings, setSettings] = useState<PlatformSettings>({});
const [loading, setLoading] = useState(true);
const [selectedSlot, setSelectedSlot] = useState<ConsultingSlot | null>(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 (
<AppLayout>
@@ -148,36 +239,73 @@ export default function AdminConsulting() {
Kelola jadwal dan link Google Meet untuk sesi konsultasi
</p>
{/* Today's Sessions Alert */}
{todaySlots.length > 0 && (
<Card className="border-2 border-primary bg-primary/5 mb-6">
<CardContent className="py-4">
<h3 className="font-bold flex items-center gap-2">
<Calendar className="w-5 h-5" />
Sesi Hari Ini ({todaySlots.length})
</h3>
<div className="mt-2 space-y-2">
{todaySlots.map(slot => (
<div key={slot.id} className="flex items-center justify-between text-sm">
<span>
{slot.start_time.substring(0, 5)} - {slot.profiles?.full_name || 'N/A'} ({slot.topic_category})
</span>
{slot.meet_link ? (
<a href={slot.meet_link} target="_blank" rel="noopener noreferrer" className="text-primary underline flex items-center gap-1">
<ExternalLink className="w-3 h-3" /> Join
</a>
) : (
<Button size="sm" variant="outline" onClick={() => openMeetDialog(slot)}>
Tambah Link
</Button>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">{confirmedSlots.length}</div>
<div className="text-2xl font-bold">{todaySlots.length}</div>
<p className="text-sm text-muted-foreground">Hari Ini</p>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">{upcomingSlots.filter(s => s.status === 'confirmed').length}</div>
<p className="text-sm text-muted-foreground">Dikonfirmasi</p>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">{pendingSlots.length}</div>
<p className="text-sm text-muted-foreground">Menunggu Pembayaran</p>
<div className="text-2xl font-bold">{upcomingSlots.filter(s => !s.meet_link && s.status === 'confirmed').length}</div>
<p className="text-sm text-muted-foreground">Perlu Link Meet</p>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{confirmedSlots.filter(s => !s.meet_link).length}
</div>
<p className="text-sm text-muted-foreground">Perlu Link Meet</p>
<div className="text-2xl font-bold">{pastSlots.filter(s => s.status === 'completed').length}</div>
<p className="text-sm text-muted-foreground">Selesai</p>
</CardContent>
</Card>
</div>
{/* Slots Table */}
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-4">
<TabsTrigger value="upcoming">Mendatang ({upcomingSlots.length})</TabsTrigger>
<TabsTrigger value="past">Riwayat ({pastSlots.length})</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle>Jadwal Konsultasi</CardTitle>
<CardDescription>Daftar semua booking konsultasi</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
@@ -192,10 +320,14 @@ export default function AdminConsulting() {
</TableRow>
</TableHeader>
<TableBody>
{slots.map((slot) => (
{upcomingSlots.map((slot) => (
<TableRow key={slot.id}>
<TableCell className="font-medium">
<div>
{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
{isToday(parseISO(slot.date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
{isTomorrow(parseISO(slot.date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
</div>
</TableCell>
<TableCell>
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
@@ -229,25 +361,43 @@ export default function AdminConsulting() {
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right space-x-2">
{slot.status === 'confirmed' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => openMeetDialog(slot)}
className="border-2"
>
<LinkIcon className="w-4 h-4 mr-2" />
{slot.meet_link ? 'Edit Link' : 'Tambah Link'}
<LinkIcon className="w-4 h-4 mr-1" />
{slot.meet_link ? 'Edit' : 'Link'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => updateSlotStatus(slot.id, 'completed')}
className="border-2 text-green-600"
>
<CheckCircle className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => updateSlotStatus(slot.id, 'cancelled')}
className="border-2 text-destructive"
>
<XCircle className="w-4 h-4" />
</Button>
</>
)}
</TableCell>
</TableRow>
))}
{slots.length === 0 && (
{upcomingSlots.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Belum ada booking konsultasi
Tidak ada jadwal mendatang
</TableCell>
</TableRow>
)}
@@ -255,6 +405,48 @@ export default function AdminConsulting() {
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="past">
<Card className="border-2 border-border">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tanggal</TableHead>
<TableHead>Waktu</TableHead>
<TableHead>Klien</TableHead>
<TableHead>Kategori</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pastSlots.slice(0, 20).map((slot) => (
<TableRow key={slot.id}>
<TableCell>{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}</TableCell>
<TableCell>{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}</TableCell>
<TableCell>{slot.profiles?.full_name || '-'}</TableCell>
<TableCell><Badge variant="outline">{slot.topic_category}</Badge></TableCell>
<TableCell>
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
{statusLabels[slot.status]?.label || slot.status}
</Badge>
</TableCell>
</TableRow>
))}
{pastSlots.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Belum ada riwayat konsultasi
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Meet Link Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -286,13 +478,31 @@ export default function AdminConsulting() {
</div>
<div className="flex gap-2">
<Button onClick={createMeetLink} variant="outline" className="flex-1 border-2">
Buat Link Meet
<Button
onClick={createMeetLink}
variant="outline"
className="flex-1 border-2"
disabled={creatingMeet}
>
{creatingMeet ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Membuat...
</>
) : (
'Buat Link Meet'
)}
</Button>
<Button onClick={saveMeetLink} disabled={saving} className="flex-1 shadow-sm">
{saving ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
{!settings.integration_n8n_base_url && (
<p className="text-xs text-muted-foreground text-center">
Tip: Konfigurasi webhook di Pengaturan Integrasi untuk pembuatan otomatis
</p>
)}
</div>
</DialogContent>
</Dialog>