import { useEffect, useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; 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 { Input } from '@/components/ui/input'; 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, isSameDay } 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 ConfirmedSession { session_date: string; start_time: string; end_time: string; } interface Webinar { id: string; title: string; event_start: string; duration_minutes: number | null; } interface TimeSlot { start: string; end: string; available: boolean; } interface Profile { whatsapp_number: string | null; } export default function ConsultingBooking() { const { user, loading: authLoading } = useAuth(); const navigate = useNavigate(); const [settings, setSettings] = useState(null); const [workhours, setWorkhours] = useState([]); const [confirmedSlots, setConfirmedSlots] = useState([]); const [webinars, setWebinars] = useState([]); const [loading, setLoading] = useState(true); const [profile, setProfile] = useState(null); const [selectedDate, setSelectedDate] = useState(addDays(new Date(), 1)); // Range selection with pending slot interface TimeRange { start: string | null; end: string | null; } const [selectedRange, setSelectedRange] = useState({ start: null, end: null }); const [pendingSlot, setPendingSlot] = useState(null); const [selectedCategory, setSelectedCategory] = useState(''); const [notes, setNotes] = useState(''); const [whatsappInput, setWhatsappInput] = useState(''); const [submitting, setSubmitting] = useState(false); useEffect(() => { fetchData(); }, []); useEffect(() => { if (selectedDate) { fetchConfirmedSlots(selectedDate); fetchWebinars(selectedDate); } }, [selectedDate]); const fetchData = async () => { const [settingsRes, workhoursRes, profileRes] = await Promise.all([ supabase.from('consulting_settings').select('*').single(), supabase.from('workhours').select('*').order('weekday'), user ? supabase.from('profiles').select('whatsapp_number').eq('id', user.id).single() : Promise.resolve({ data: null }), ]); if (settingsRes.data) setSettings(settingsRes.data); if (workhoursRes.data) setWorkhours(workhoursRes.data); if (profileRes.data) setProfile(profileRes.data); setLoading(false); }; const fetchConfirmedSlots = async (date: Date) => { const dateStr = format(date, 'yyyy-MM-dd'); const { data } = await supabase .from('consulting_sessions') .select('session_date, start_time, end_time') .eq('session_date', dateStr) .in('status', ['pending_payment', 'confirmed']); if (data) setConfirmedSlots(data); }; const fetchWebinars = async (date: Date) => { const dateStr = format(date, 'yyyy-MM-dd'); const { data } = await supabase .from('products') .select('id, title, event_start, duration_minutes') .eq('type', 'webinar') .eq('is_active', true) .like('event_start', `${dateStr}%`); if (data) setWebinars(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; const now = new Date(); const isToday = isSameDay(selectedDate, now); 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 consulting 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); }); // Check if slot conflicts with webinars const webinarConflict = webinars.some(w => { const webinarStart = new Date(w.event_start); const webinarDurationMs = (w.duration_minutes || 60) * 60 * 1000; const webinarEnd = new Date(webinarStart.getTime() + webinarDurationMs); const slotStartTime = new Date(selectedDate); slotStartTime.setHours(parseInt(slotStart.split(':')[0]), parseInt(slotStart.split(':')[1]), 0); const slotEndTime = new Date(selectedDate); slotEndTime.setHours(parseInt(slotEnd.split(':')[0]), parseInt(slotEnd.split(':')[1]), 0); // Block if slot overlaps with webinar time return slotStartTime < webinarEnd && slotEndTime > webinarStart; }); // Check if slot is in the past for today const isPassed = isToday && isBefore(current, now); slots.push({ start: slotStart, end: slotEnd, available: !isConflict && !webinarConflict && !isPassed, }); current = addMinutes(current, duration); } } return slots; }, [selectedDate, workhours, confirmedSlots, webinars, settings]); // Helper: Get all slots between start and end (inclusive) // Now supports single slot selection where start = end const getSlotsInRange = useMemo(() => { // If there's a pending slot but no confirmed range, don't show any slots as selected if (pendingSlot && !selectedRange.start) return []; // If only start is set (no end), don't show any slots as selected yet if (!selectedRange.start || !selectedRange.end) return []; const startIndex = availableSlots.findIndex(s => s.start === selectedRange.start); const endIndex = availableSlots.findIndex(s => s.start === selectedRange.end); if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) return []; return availableSlots .slice(startIndex, endIndex + 1) .map(s => s.start); }, [selectedRange, availableSlots, pendingSlot]); // Range selection handler with pending slot UX const handleSlotClick = (slotStart: string) => { const slot = availableSlots.find(s => s.start === slotStart); if (!slot || !slot.available) return; // If there's a pending slot if (pendingSlot) { if (slotStart === pendingSlot) { // Clicked same slot again → Confirm single slot selection setSelectedRange({ start: slotStart, end: slotStart }); setPendingSlot(null); } else { // Clicked different slot → First becomes start, second becomes end const pendingIndex = availableSlots.findIndex(s => s.start === pendingSlot); const clickIndex = availableSlots.findIndex(s => s.start === slotStart); if (clickIndex < pendingIndex) { // Clicked before pending → Make clicked slot start, pending becomes end setSelectedRange({ start: slotStart, end: pendingSlot }); } else { // Clicked after pending → Pending is start, clicked is end setSelectedRange({ start: pendingSlot, end: slotStart }); } setPendingSlot(null); } return; } // No pending slot - check if we're modifying existing selection if (selectedRange.start && selectedRange.end) { const startIndex = availableSlots.findIndex(s => s.start === selectedRange.start); const endIndex = availableSlots.findIndex(s => s.start === selectedRange.end); const clickIndex = availableSlots.findIndex(s => s.start === slotStart); // Clicked start time → Clear all if (slotStart === selectedRange.start) { setSelectedRange({ start: null, end: null }); return; } // Clicked end time → Remove end, keep start as pending if (slotStart === selectedRange.end) { setPendingSlot(selectedRange.start); setSelectedRange({ start: null, end: null }); return; } // Clicked before start → New start, old start becomes end if (clickIndex < startIndex) { setSelectedRange({ start: slotStart, end: selectedRange.start }); return; } // Clicked after end → New end if (clickIndex > endIndex) { setSelectedRange({ start: selectedRange.start, end: slotStart }); return; } // Clicked within range → Update end to clicked slot setSelectedRange({ start: selectedRange.start, end: slotStart }); return; } // No selection at all → Set as pending setPendingSlot(slotStart); }; // Calculate total blocks from range const totalBlocks = getSlotsInRange.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 (getSlotsInRange.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 { // Save WhatsApp number if provided and not already saved if (whatsappInput && !profile?.whatsapp_number) { let normalized = whatsappInput.replace(/\D/g, ''); if (normalized.startsWith('0')) normalized = '62' + normalized.substring(1); if (!normalized.startsWith('+')) normalized = '+' + normalized; await supabase.from('profiles').update({ whatsapp_number: normalized }).eq('id', user.id); } // 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', payment_method: 'qris', }) .select() .single(); if (orderError) throw orderError; // Create consulting session and time slots const firstSlotStart = getSlotsInRange[0]; const lastSlotEnd = format( addMinutes(parse(getSlotsInRange[getSlotsInRange.length - 1], 'HH:mm', new Date()), settings.consulting_block_duration_minutes), 'HH:mm' ); // Calculate session duration in minutes const sessionDurationMinutes = totalBlocks * settings.consulting_block_duration_minutes; // Create the session record (ONE row per booking) const { data: session, error: sessionError } = await supabase .from('consulting_sessions') .insert({ user_id: user.id, order_id: order.id, session_date: format(selectedDate, 'yyyy-MM-dd'), start_time: firstSlotStart + ':00', end_time: lastSlotEnd + ':00', total_duration_minutes: sessionDurationMinutes, topic_category: selectedCategory, notes: notes, status: 'pending_payment', total_blocks: totalBlocks, total_price: totalPrice, }) .select() .single(); if (sessionError) throw sessionError; // Create time slots for availability tracking (MULTIPLE rows per booking) const timeSlotsToInsert = getSlotsInRange.map(slotStart => { const slotEnd = format( addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes), 'HH:mm' ); return { session_id: session.id, slot_date: format(selectedDate, 'yyyy-MM-dd'), start_time: slotStart + ':00', end_time: slotEnd + ':00', is_available: false, booked_at: new Date().toISOString(), }; }); const { error: timeSlotsError } = await supabase.from('consulting_time_slots').insert(timeSlotsToInsert); if (timeSlotsError) throw timeSlotsError; // Call edge function to create payment with QR code const { data: paymentData, error: paymentError } = await supabase.functions.invoke('create-payment', { body: { order_id: order.id, amount: totalPrice, description: `Konsultasi 1-on-1 (${totalBlocks} blok)`, method: 'qris', }, }); if (paymentError) { console.error('Payment creation error:', paymentError); throw new Error(paymentError.message || 'Gagal membuat pembayaran'); } // Navigate to order detail page to show QR code navigate(`/orders/${order.id}`); } catch (error: any) { toast({ title: 'Error', description: error.message, variant: 'destructive' }); } finally { setSubmitting(false); } }; if (loading || authLoading) { return (
); } // Require authentication to access consulting booking if (!user) { return (
); } if (!settings?.is_consulting_enabled) { return (

Layanan Konsultasi Tidak Tersedia

Layanan konsultasi sedang tidak aktif.

); } return (

Pilih waktu dan kategori untuk sesi konsultasi pribadi

{/* Calendar & Slots */}
Pilih Tanggal date < startOfDay(new Date()) || date.getDay() === 0} locale={id} className="rounded-md border-2" /> {selectedDate && ( Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })} Klik satu slot untuk memilih, klik lagi untuk konfirmasi. Atau klik dua slot berbeda untuk rentang waktu. {settings.consulting_block_duration_minutes} menit per blok. {webinars.length > 0 && ( ⚠️ {webinars.length} webinar terjadwal - beberapa slot mungkin tidak tersedia )} {availableSlots.length === 0 ? (

Tidak ada slot tersedia pada hari ini

) : (
{availableSlots.map((slot, index) => { const isSelected = getSlotsInRange.includes(slot.start); const isPending = slot.start === pendingSlot; const isStart = slot.start === selectedRange.start; const isEnd = slot.start === selectedRange.end; const isMiddle = isSelected && !isStart && !isEnd; // Determine button variant let variant: "default" | "outline" = "outline"; if (isSelected) variant = "default"; // Determine border radius for seamless connection let className = "border-2 h-10"; // Add special styling for pending slot if (isPending) { className += " bg-amber-500 hover:bg-amber-600 text-white border-amber-600"; } if (isStart) { // First selected slot - right side should connect className += index < availableSlots.length - 1 && availableSlots[index + 1]?.start === getSlotsInRange[1] ? " rounded-r-none border-r-0" : ""; } else if (isEnd) { // Last selected slot - left side should connect className += " rounded-l-none border-l-0"; } else if (isMiddle) { // Middle slot - seamless className += " rounded-none border-x-0"; } return ( ); })}
)}
)} Kategori Konsultasi
{categories.map((cat) => ( ))}
Catatan (Opsional)