- 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>
708 lines
27 KiB
TypeScript
708 lines
27 KiB
TypeScript
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>
|
||
);
|
||
}
|