This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 13:07:23 +00:00
parent 277f7506c3
commit 7f1622613c
6 changed files with 1500 additions and 156 deletions

View File

@@ -0,0 +1,424 @@
import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useCart } from '@/contexts/CartContext';
import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Calendar } from '@/components/ui/calendar';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast';
import { formatIDR } from '@/lib/format';
import { Video, Clock, Calendar as CalendarIcon, MessageSquare } from 'lucide-react';
import { format, addMinutes, parse, isAfter, isBefore, startOfDay, addDays } from 'date-fns';
import { id } from 'date-fns/locale';
interface ConsultingSettings {
id: string;
is_consulting_enabled: boolean;
consulting_block_price: number;
consulting_block_duration_minutes: number;
consulting_categories: string;
}
interface Workhour {
id: string;
weekday: number;
start_time: string;
end_time: string;
}
interface ConfirmedSlot {
date: string;
start_time: string;
end_time: string;
}
interface TimeSlot {
start: string;
end: string;
available: boolean;
}
export default function ConsultingBooking() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const { addItem } = useCart();
const [settings, setSettings] = useState<ConsultingSettings | null>(null);
const [workhours, setWorkhours] = useState<Workhour[]>([]);
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
const [selectedSlots, setSelectedSlots] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
if (selectedDate) {
fetchConfirmedSlots(selectedDate);
}
}, [selectedDate]);
const fetchData = async () => {
const [settingsRes, workhoursRes] = await Promise.all([
supabase.from('consulting_settings').select('*').single(),
supabase.from('workhours').select('*').order('weekday'),
]);
if (settingsRes.data) setSettings(settingsRes.data);
if (workhoursRes.data) setWorkhours(workhoursRes.data);
setLoading(false);
};
const fetchConfirmedSlots = async (date: Date) => {
const dateStr = format(date, 'yyyy-MM-dd');
const { data } = await supabase
.from('consulting_slots')
.select('date, start_time, end_time')
.eq('date', dateStr)
.in('status', ['pending_payment', 'confirmed']);
if (data) setConfirmedSlots(data);
};
const categories = useMemo(() => {
if (!settings?.consulting_categories) return [];
return settings.consulting_categories.split(',').map(c => c.trim()).filter(Boolean);
}, [settings?.consulting_categories]);
const availableSlots = useMemo((): TimeSlot[] => {
if (!selectedDate || !settings) return [];
const dayOfWeek = selectedDate.getDay();
const dayWorkhours = workhours.filter(w => w.weekday === dayOfWeek);
if (dayWorkhours.length === 0) return [];
const slots: TimeSlot[] = [];
const duration = settings.consulting_block_duration_minutes;
for (const wh of dayWorkhours) {
let current = parse(wh.start_time, 'HH:mm:ss', selectedDate);
const end = parse(wh.end_time, 'HH:mm:ss', selectedDate);
while (isBefore(addMinutes(current, duration), end) || format(addMinutes(current, duration), 'HH:mm') === format(end, 'HH:mm')) {
const slotStart = format(current, 'HH:mm');
const slotEnd = format(addMinutes(current, duration), 'HH:mm');
// Check if slot conflicts with confirmed/pending slots
const isConflict = confirmedSlots.some(cs => {
const csStart = cs.start_time.substring(0, 5);
const csEnd = cs.end_time.substring(0, 5);
return !(slotEnd <= csStart || slotStart >= csEnd);
});
slots.push({
start: slotStart,
end: slotEnd,
available: !isConflict,
});
current = addMinutes(current, duration);
}
}
return slots;
}, [selectedDate, workhours, confirmedSlots, settings]);
const toggleSlot = (slotStart: string) => {
setSelectedSlots(prev =>
prev.includes(slotStart)
? prev.filter(s => s !== slotStart)
: [...prev, slotStart]
);
};
const totalBlocks = selectedSlots.length;
const totalPrice = totalBlocks * (settings?.consulting_block_price || 0);
const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30);
const handleBookNow = async () => {
if (!user) {
toast({ title: 'Login diperlukan', description: 'Silakan login untuk melanjutkan', variant: 'destructive' });
navigate('/auth');
return;
}
if (selectedSlots.length === 0) {
toast({ title: 'Pilih slot', description: 'Pilih minimal satu slot waktu', variant: 'destructive' });
return;
}
if (!selectedCategory) {
toast({ title: 'Pilih kategori', description: 'Pilih kategori konsultasi', variant: 'destructive' });
return;
}
if (!selectedDate || !settings) return;
setSubmitting(true);
try {
// Create order
const { data: order, error: orderError } = await supabase
.from('orders')
.insert({
user_id: user.id,
total_amount: totalPrice,
status: 'pending',
payment_status: 'pending',
payment_provider: 'pakasir',
})
.select()
.single();
if (orderError) throw orderError;
// Create consulting slots
const slotsToInsert = selectedSlots.map(slotStart => {
const slotEnd = format(
addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes),
'HH:mm'
);
return {
user_id: user.id,
order_id: order.id,
date: format(selectedDate, 'yyyy-MM-dd'),
start_time: slotStart + ':00',
end_time: slotEnd + ':00',
status: 'pending_payment',
topic_category: selectedCategory,
notes: notes,
};
});
const { error: slotsError } = await supabase.from('consulting_slots').insert(slotsToInsert);
if (slotsError) throw slotsError;
// Add to cart for Pakasir checkout
addItem({
id: `consulting-${order.id}`,
title: `Konsultasi 1-on-1 (${totalBlocks} blok)`,
price: totalPrice,
sale_price: null,
type: 'consulting',
});
toast({ title: 'Berhasil', description: 'Silakan lanjutkan ke pembayaran' });
navigate('/checkout');
} catch (error: any) {
toast({ title: 'Error', description: error.message, variant: 'destructive' });
} finally {
setSubmitting(false);
}
};
if (loading || authLoading) {
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>
);
}
if (!settings?.is_consulting_enabled) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 text-center">
<h1 className="text-2xl font-bold mb-4">Layanan Konsultasi Tidak Tersedia</h1>
<p className="text-muted-foreground">Layanan konsultasi sedang tidak aktif.</p>
<Button onClick={() => navigate('/products')} className="mt-4">
Lihat Produk Lain
</Button>
</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">
<Video className="w-10 h-10" />
Konsultasi 1-on-1
</h1>
<p className="text-muted-foreground mb-8">
Pilih waktu dan kategori untuk sesi konsultasi pribadi
</p>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Calendar & Slots */}
<div className="lg:col-span-2 space-y-6">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5" />
Pilih Tanggal
</CardTitle>
</CardHeader>
<CardContent>
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
disabled={(date) => date < startOfDay(new Date()) || date.getDay() === 0}
locale={id}
className="rounded-md border-2"
/>
</CardContent>
</Card>
{selectedDate && (
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })}
</CardTitle>
<CardDescription>
Klik slot untuk memilih. {settings.consulting_block_duration_minutes} menit per blok.
</CardDescription>
</CardHeader>
<CardContent>
{availableSlots.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
Tidak ada slot tersedia pada hari ini
</p>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{availableSlots.map((slot) => (
<Button
key={slot.start}
variant={selectedSlots.includes(slot.start) ? 'default' : 'outline'}
disabled={!slot.available}
onClick={() => slot.available && toggleSlot(slot.start)}
className="border-2"
>
{slot.start}
</Button>
))}
</div>
)}
</CardContent>
</Card>
)}
<Card className="border-2 border-border">
<CardHeader>
<CardTitle>Kategori Konsultasi</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<Button
key={cat}
variant={selectedCategory === cat ? 'default' : 'outline'}
onClick={() => setSelectedCategory(cat)}
className="border-2"
>
{cat}
</Button>
))}
</div>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
Catatan (Opsional)
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Jelaskan topik atau pertanyaan yang ingin dibahas..."
className="border-2 min-h-[100px]"
/>
</CardContent>
</Card>
</div>
{/* Summary */}
<div className="lg:col-span-1">
<Card className="border-2 border-border sticky top-4">
<CardHeader>
<CardTitle>Ringkasan Booking</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between">
<span className="text-muted-foreground">Tanggal</span>
<span className="font-medium">
{selectedDate ? format(selectedDate, 'd MMM yyyy', { locale: id }) : '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Jumlah Blok</span>
<span className="font-medium">{totalBlocks} blok</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total Durasi</span>
<span className="font-medium">{totalDuration} menit</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Kategori</span>
<span className="font-medium">{selectedCategory || '-'}</span>
</div>
{selectedSlots.length > 0 && (
<div className="pt-4 border-t">
<p className="text-sm text-muted-foreground mb-2">Slot dipilih:</p>
<div className="flex flex-wrap gap-1">
{selectedSlots.sort().map((slot) => (
<span key={slot} className="px-2 py-1 bg-primary/10 text-primary rounded text-sm">
{slot}
</span>
))}
</div>
</div>
)}
<div className="pt-4 border-t">
<div className="flex justify-between text-lg font-bold">
<span>Total</span>
<span>{formatIDR(totalPrice)}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatIDR(settings.consulting_block_price)} × {totalBlocks} blok
</p>
</div>
<Button
onClick={handleBookNow}
disabled={submitting || selectedSlots.length === 0 || !selectedCategory}
className="w-full shadow-sm"
>
{submitting ? 'Memproses...' : 'Booking Sekarang'}
</Button>
<p className="text-xs text-muted-foreground text-center">
Anda akan diarahkan ke halaman pembayaran
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</AppLayout>
);
}