Files
meet-hub/src/pages/ConsultingBooking.tsx
dwindown 5ab4e6b974 Add calendar event lifecycle management and "Add to Calendar" feature
- Migrate consulting_slots to consulting_sessions structure
- Add calendar_event_id to track Google Calendar events
- Create delete-calendar-event edge function for auto-cleanup
- Add "Tambah ke Kalender" button for members (OrderDetail, ConsultingHistory)
- Update create-google-meet-event to store calendar event ID
- Update handle-order-paid to use consulting_sessions table
- Remove deprecated create-meet-link function
- Add comprehensive documentation (CALENDAR_INTEGRATION.md, MIGRATION_GUIDE.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-28 13:54:16 +07:00

708 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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<ConsultingSettings | null>(null);
const [workhours, setWorkhours] = useState<Workhour[]>([]);
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
const [webinars, setWebinars] = useState<Webinar[]>([]);
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<Profile | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
// Range selection with pending slot
interface TimeRange {
start: string | null;
end: string | null;
}
const [selectedRange, setSelectedRange] = useState<TimeRange>({ start: null, end: null });
const [pendingSlot, setPendingSlot] = useState<string | null>(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 (
<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>
);
}
// Require authentication to access consulting booking
if (!user) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-16 text-center">
<div className="max-w-md mx-auto">
<Video className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
<h1 className="text-2xl font-bold mb-2">Login Diperlukan</h1>
<p className="text-muted-foreground mb-6">
Anda harus login untuk memesan jadwal konsultasi.
</p>
<Button onClick={() => navigate('/auth')} size="lg">
Login Sekarang
</Button>
</div>
</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 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 && (
<span className="block mt-1 text-amber-600 dark:text-amber-400">
{webinars.length} webinar terjadwal - beberapa slot mungkin tidak tersedia
</span>
)}
</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-0">
{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 (
<Button
key={slot.start}
variant={isPending ? "default" : variant}
disabled={!slot.available}
onClick={() => slot.available && handleSlotClick(slot.start)}
className={className}
>
{isPending && <span className="text-xs opacity-70">Pilih</span>}
{isStart && !isPending && <span className="text-xs opacity-70">Mulai</span>}
{!isPending && !isStart && !isEnd && slot.start}
{isEnd && !isPending && <span className="text-xs opacity-70">Selesai</span>}
</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 className="space-y-4">
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Jelaskan topik atau pertanyaan yang ingin dibahas..."
className="border-2 min-h-[100px]"
/>
{/* WhatsApp prompt if not saved */}
{user && !profile?.whatsapp_number && (
<div className="space-y-2 pt-2 border-t border-border">
<Label className="text-sm">Nomor WhatsApp untuk pengingat sesi ini (opsional)</Label>
<Input
value={whatsappInput}
onChange={(e) => setWhatsappInput(e.target.value)}
placeholder="08123456789"
className="border-2"
/>
<p className="text-xs text-muted-foreground">
Akan otomatis tersimpan ke profil Anda
</p>
</div>
)}
</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">Kategori</span>
<span className="font-medium">{selectedCategory || '-'}</span>
</div>
{selectedRange.start && selectedRange.end && (
<div className="pt-4 border-t">
<p className="text-sm text-muted-foreground mb-2">Waktu dipilih:</p>
{/* Show range */}
<div className="bg-primary/10 p-3 rounded-lg border-2 border-primary/20">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">Mulai</p>
<p className="font-bold text-lg">{selectedRange.start}</p>
</div>
<div className="text-center">
<p className="text-2xl"></p>
<p className="text-xs text-muted-foreground">{totalBlocks} blok</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Selesai</p>
<p className="font-bold text-lg">{selectedRange.end}</p>
</div>
</div>
<p className="text-center text-sm mt-2 text-primary font-medium">
{totalDuration} menit ({formatIDR(totalPrice)})
</p>
</div>
</div>
)}
{pendingSlot && !selectedRange.start && (
<div className="pt-4 border-t">
<p className="text-sm text-muted-foreground mb-2">Slot dipilih:</p>
{/* Show pending slot */}
<div className="bg-amber-500/10 p-3 rounded-lg border-2 border-amber-500/20">
<div className="text-center">
<p className="text-xs text-muted-foreground">Klik lagi untuk konfirmasi, atau pilih slot lain</p>
<p className="font-bold text-lg text-amber-600">{pendingSlot}</p>
<p className="text-xs text-muted-foreground mt-1">1 blok = {settings.consulting_block_duration_minutes} menit ({formatIDR(settings.consulting_block_price)})</p>
</div>
</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 || getSlotsInRange.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>
);
}