Files
meet-hub/src/pages/ConsultingBooking.tsx
dwindown 3eb53406c9 Auto-cancel expired consulting orders and prefill re-booking
**Features Implemented:**

1. **Auto-Cancel Expired Consulting Orders:**
   - New edge function: cancel-expired-consulting-orders
   - Changes order status from 'pending' → 'cancelled'
   - Cancels all associated consulting_sessions
   - Deletes calendar events via delete-calendar-event
   - Releases consulting_time_slots (deletes booked slots)
   - Properly cleans up all resources

2. **Smart Re-Booking with Pre-filled Data:**
   - OrderDetail.tsx stores expired order data in sessionStorage:
     - fromExpiredOrder flag
     - Original orderId
     - topicCategory
     - notes
   - ConsultingBooking.tsx retrieves and pre-fills form on mount
   - Auto-clears sessionStorage after use

3. **Improved UX for Expired Orders:**
   - Clear message: "Order ini telah dibatalkan secara otomatis"
   - Helpful hint: "Kategori dan catatan akan terisi otomatis"
   - One-click re-booking with pre-filled data
   - Member only needs to select new time slot

**How It Works:**

Flow:
1. QRIS expires → Order shows expired message
2. Member clicks "Buat Booking Baru"
3. Data stored in sessionStorage (category, notes)
4. Navigates to /consulting
5. Form auto-fills with previous data
6. Member selects new time → Books new session

**Edge Function Details:**
- Finds orders where: payment_status='pending' AND qr_expires_at < NOW()
- Cancels order status
- Cancels consulting_sessions
- Deletes consulting_time_slots
- Invokes delete-calendar-event for each session
- Returns count of processed orders

**To Deploy:**
1. Deploy cancel-expired-consulting-orders edge function
2. Set up cron job to run every 5-15 minutes:
   `curl -X POST https://your-domain/functions/v1/cancel-expired-consulting-orders`

**Benefits:**
 Orders properly cancelled when QR expires
 Time slots released for other users
 Calendar events cleaned up
 Easy re-booking without re-typing data
 Better UX for expired payment situations

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

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

739 lines
28 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();
// Check for pre-filled data from expired order
const expiredOrderData = sessionStorage.getItem('expiredConsultingOrder');
if (expiredOrderData) {
try {
const data = JSON.parse(expiredOrderData);
if (data.fromExpiredOrder) {
// Prefill form with expired order data
if (data.topicCategory) setSelectedCategory(data.topicCategory);
if (data.notes) setNotes(data.notes);
// Show notification to user
setTimeout(() => {
// You could add a toast notification here if you have toast set up
console.log('Pre-filled data from expired order:', data);
}, 100);
// Clear the stored data after using it
sessionStorage.removeItem('expiredConsultingOrder');
}
} catch (err) {
console.error('Error parsing expired order data:', err);
sessionStorage.removeItem('expiredConsultingOrder');
}
}
}, []);
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">
{(() => {
const start = parse(selectedRange.end, 'HH:mm', new Date());
const end = addMinutes(start, settings?.consulting_block_duration_minutes || 30);
return format(end, 'HH:mm');
})()}
</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>
);
}