Files
meet-hub/src/pages/ConsultingBooking.tsx
gpt-engineer-app[bot] 7f1622613c Changes
2025-12-19 13:07:23 +00:00

425 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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