import { Activity, ArrowLeft, ArrowRight, CalendarDays, CalendarPlus, Check, ChevronLeft, ChevronRight, Clock3, Copy, CreditCard, ExternalLink, Inbox, Loader2, LogOut, MapPin, MonitorUp, Phone, Search, Settings2, Sparkles, Users, Video, } from 'lucide-react'; import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; type MeetingMode = 'google_meet' | 'phone' | 'in_person' | 'decide_later'; type PublicConfig = { title: string; subtitle: string; timezone: string; bookingWindowDays: number; durations: number[]; defaultMeetingMode: MeetingMode; allowMeetingModes: MeetingMode[]; availableWeekdays: number[]; connected: boolean; payment?: PaymentOption; }; type PaymentOption = { enabled: true; required: boolean; ready: boolean; ratePerMinute: number; freeDurations: number[]; currency: string; label: string; }; type DurationBlockReason = 'outside_hours' | 'unavailable'; type Slot = { start: string; time: string; durations: number[]; blockedDurations?: Record; }; type Confirmation = { id: string; bookingId?: string | null; start: string; end: string; duration: number; timezone: string; meetingMode: MeetingMode; meetUrl?: string | null; receiptUrl?: string | null; }; type AppSettings = { title: string; subtitle: string; timezone: string; bookingWindowDays: number; minimumNoticeMinutes: number; bufferBeforeMinutes: number; bufferAfterMinutes: number; slotIncrementMinutes: number; durations: number[]; calendarId: string; busyCalendarIds: string[]; defaultMeetingMode: MeetingMode; allowMeetingModes: MeetingMode[]; inPersonLocation: string; paymentEnabled: boolean; paymentRatePerMinute: number; paymentFreeDurations: number[]; paymentCurrency: string; paymentLabel: string; availability: Record>; }; type BookingRow = { id: string; public_booking_id?: string | null; name: string; email: string; start_time: string; end_time: string; duration_minutes: number; meeting_mode: MeetingMode; status: string; meet_url?: string; calendar_provider?: string; calendar_sync_status?: string; calendar_sync_error?: string; calendar_sync_updated_at?: string; payment_status?: string; payment_amount?: number; payment_currency?: string; stripe_checkout_session_id?: string; stripe_payment_intent_id?: string; paid_at?: string; cancelled_at?: string; created_at?: string; rescheduled_at?: string; reschedule_count?: number; proposed_start_time?: string; proposed_end_time?: string; proposed_at?: string; proposal_status?: string; refund_status?: string; stripe_refund_id?: string; refunded_amount?: number; refunded_at?: string; }; type ActivityStats = { total_bookings: number; upcoming: number; completed: number; cancelled: number; reschedules: number; paid_bookings: number; average_minutes: number; open_payment_holds: number; }; type ActivityMoney = { currency: string; revenue: number; refunds: number; net: number; }; type ActivityEvent = { id: string; booking_id?: string | null; type: string; occurred_at: string; metadata: Record; name?: string | null; email?: string | null; start_time?: string | null; end_time?: string | null; duration_minutes?: number | null; payment_amount?: number | null; payment_currency?: string | null; }; type ActivityData = { paymentsEnabled: boolean; stats: ActivityStats; money: ActivityMoney[]; events: ActivityEvent[]; }; type AttendeeRow = { email: string; name: string; total_bookings: number; upcoming: number; completed: number; cancelled: number; reschedules: number; paid_bookings: number; refunded_bookings: number; total_minutes: number; average_minutes: number; first_booked_at?: string | null; last_booked_at?: string | null; }; type AttendeeData = { paymentsEnabled: boolean; attendees: AttendeeRow[]; }; type RescheduleProposal = { title: string; name: string; currentStart: string; currentEnd: string; proposedStart: string; proposedEnd: string; duration: number; timezone: string; meetingMode: MeetingMode; }; type ManagedBooking = { bookingId: string; name: string; start: string; end: string; duration: number; timezone: string; meetingMode: MeetingMode; status: string; paymentStatus: string; refundStatus: string; canCancel: boolean; canReschedule: boolean; }; type ManageAction = 'none' | 'cancel' | 'reschedule'; type BookingScheduleAction = 'reschedule' | 'suggest'; type RecurringUnavailableRule = { id: string; weekdays: number[]; start: string; end: string; label?: string | null; }; type AvailabilityBlock = { id: string; date: string; start?: string | null; end?: string | null; allDay: boolean; label?: string | null; }; type AdminSession = { authenticated: boolean; email: string; connected: boolean }; type ThemeId = 'light' | 'paper' | 'beige' | 'dark' | 'midnight' | 'rosewood' | 'forest' | 'true-black'; const modeMeta: Record = { google_meet: { label: 'Google Meet', icon: Video, help: 'A Meet link is created automatically.' }, phone: { label: 'Phone', icon: Phone, help: 'Add a number for the call.' }, in_person: { label: 'In person', icon: MapPin, help: 'The location will be included in the invite.' }, decide_later: { label: 'Decide later', icon: Sparkles, help: 'Book the time first and sort out the place later.' }, }; const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const shortDayNames = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']; const timezoneOptions = [ 'Europe/Stockholm', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Madrid', 'Europe/Amsterdam', 'Europe/Helsinki', 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', 'Asia/Tokyo', 'Asia/Singapore', 'Australia/Sydney', ]; const themes: Array<{ id: ThemeId; name: string; description: string; contrast: string }> = [ { id: 'light', name: 'Light', description: 'Bright surfaces with dark text and clear contrast.', contrast: 'Light contrast' }, { id: 'paper', name: 'Paper', description: 'Cool, document-like surfaces with crisp separation.', contrast: 'Crisp contrast' }, { id: 'beige', name: 'Beige', description: 'Warm neutral surfaces for a softer reading experience.', contrast: 'Warm contrast' }, { id: 'dark', name: 'Dark', description: 'Dark grey surfaces that reduce glare while keeping depth.', contrast: 'Soft contrast' }, { id: 'midnight', name: 'Midnight', description: 'Deep navy surfaces for calm late-night work.', contrast: 'Cool contrast' }, { id: 'rosewood', name: 'Rosewood', description: 'Warm dark surfaces with copper and walnut tones.', contrast: 'Warm dark' }, { id: 'forest', name: 'Forest', description: 'Muted pine surfaces with restrained natural contrast.', contrast: 'Natural dark' }, { id: 'true-black', name: 'True black', description: 'Pure black backgrounds with maximum dark-mode contrast.', contrast: 'High contrast' }, ]; async function api(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, headers: { ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...(init?.headers ?? {}), }, }); const data = (await response.json().catch(() => ({}))) as T & { error?: string }; if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`); return data; } function checkoutSessionMode(sessionId?: string): 'test' | 'live' | 'unknown' { if (sessionId?.startsWith('cs_test_')) return 'test'; if (sessionId?.startsWith('cs_live_')) return 'live'; return 'unknown'; } function dateKey(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function parseDateKey(value: string): Date { const [y, m, d] = value.split('-').map(Number); return new Date(y, m - 1, d, 12, 0, 0); } function addDays(date: Date, amount: number): Date { const copy = new Date(date); copy.setDate(copy.getDate() + amount); return copy; } function todayInZone(timezone: string): string { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(new Date()); const map = Object.fromEntries(parts.filter((p) => p.type !== 'literal').map((p) => [p.type, p.value])); return `${map.year}-${map.month}-${map.day}`; } function dateKeyForInstant(iso: string, timezone: string): string { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(new Date(iso)); const map = Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])); return `${map.year}-${map.month}-${map.day}`; } function prettyDate(key: string, timezone: string): string { return new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'long', month: 'long', day: 'numeric', }).format(new Date(`${key}T12:00:00Z`)); } function dayAriaLabel(date: Date): string { return new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', }).format(date); } function formatInstant(iso: string, timezone: string): string { return new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).format(new Date(iso)); } function formatDateShort(iso: string, timezone: string): string { return new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'short', month: 'short', day: 'numeric', }).format(new Date(iso)); } function formatClock(iso: string, timezone: string): string { return new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).format(new Date(iso)); } function formatMoneyMinor(amountMinor: number, currency: string): string { try { const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase(), }); const digits = formatter.resolvedOptions().maximumFractionDigits ?? 2; return formatter.format(amountMinor / (10 ** digits)); } catch { return `${amountMinor} ${currency.toUpperCase()}`; } } function formatMoneyMajor(amount: number, currency: string): string { try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase(), maximumFractionDigits: 2, }).format(amount); } catch { return `${amount.toFixed(2)} ${currency.toUpperCase()}`; } } function databaseInstant(value: string): string { if (/Z$|[+-]\d{2}:?\d{2}$/.test(value)) return value; return `${value.replace(' ', 'T')}Z`; } function activityMoneyText(money: ActivityMoney[], field: 'revenue' | 'refunds' | 'net', preferredCurrency: string): string { if (!money.length) return formatMoneyMinor(0, preferredCurrency); const sorted = [...money].sort((a, b) => { if (a.currency === preferredCurrency.toLowerCase()) return -1; if (b.currency === preferredCurrency.toLowerCase()) return 1; return a.currency.localeCompare(b.currency); }); return sorted.map((row) => formatMoneyMinor(row[field], row.currency)).join(' · '); } function activityEventCopy(event: ActivityEvent, timezone: string): { title: string; detail: string } { const name = event.name || 'A visitor'; const meta = event.metadata ?? {}; const amount = typeof meta.amount === 'number' ? meta.amount : event.payment_amount; const currency = typeof meta.currency === 'string' ? meta.currency : event.payment_currency; const money = amount != null && currency ? formatMoneyMinor(amount, currency) : null; const duration = typeof meta.duration === 'number' ? meta.duration : event.duration_minutes; const eventStart = typeof meta.newStart === 'string' ? meta.newStart : typeof meta.proposedStart === 'string' ? meta.proposedStart : typeof meta.start === 'string' ? meta.start : event.start_time; const oldStart = typeof meta.oldStart === 'string' ? meta.oldStart : null; switch (event.type) { case 'booking_confirmed': return { title: `${name} booked a meeting`, detail: `${duration ?? ''}${duration ? ' min · ' : ''}${eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Confirmed'}` }; case 'payment_checkout_started': return { title: `${name} started checkout`, detail: `${money ? `${money} · ` : ''}${eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Payment pending'}` }; case 'payment_received': return { title: `Payment received from ${name}`, detail: money ?? 'Stripe payment completed' }; case 'payment_hold_released': return { title: `${name}'s payment hold was released`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)} is available again` : 'The held time is available again' }; case 'booking_rescheduled': return { title: `${name}'s meeting was rescheduled`, detail: `${oldStart ? `${formatDateShort(oldStart, timezone)} ${formatClock(oldStart, timezone)} → ` : ''}${eventStart ? `${formatDateShort(eventStart, timezone)} ${formatClock(eventStart, timezone)}` : 'New time saved'}` }; case 'time_suggested': return { title: `New time suggested to ${name}`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Waiting for the guest' }; case 'time_suggestion_accepted': return { title: `${name} accepted the suggested time`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Meeting rescheduled' }; case 'refund_started': return { title: `Refund started for ${name}`, detail: money ?? 'Stripe is processing the refund' }; case 'refund_succeeded': return { title: `Refund completed for ${name}`, detail: money ?? 'Stripe refund completed' }; case 'refund_failed': return { title: `Refund failed for ${name}`, detail: money ? `${money} · Check Stripe` : 'Check Stripe for the failure details' }; case 'booking_cancelled': return { title: `${name}'s meeting was cancelled`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Booking cancelled' }; default: return { title: event.type.replaceAll('_', ' '), detail: event.email ?? 'Slot activity' }; } } function monthCells(month: Date): Array<{ date: Date; current: boolean }> { const first = new Date(month.getFullYear(), month.getMonth(), 1, 12); const mondayIndex = (first.getDay() + 6) % 7; const start = addDays(first, -mondayIndex); return Array.from({ length: 42 }, (_, index) => { const date = addDays(start, index); return { date, current: date.getMonth() === month.getMonth() }; }); } function googleCalendarUrl(title: string, conf: Confirmation): string { const stamp = (iso: string) => iso.replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const params = new URLSearchParams({ action: 'TEMPLATE', text: title, dates: `${stamp(conf.start)}/${stamp(conf.end)}`, }); if (conf.meetUrl) params.set('details', `Google Meet: ${conf.meetUrl}`); return `https://calendar.google.com/calendar/render?${params.toString()}`; } function useCopy(): [boolean, (value: string) => void] { const [copied, setCopied] = useState(false); const timer = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(timer.current), []); function copy(value: string) { navigator.clipboard?.writeText(value).then(() => { setCopied(true); clearTimeout(timer.current); timer.current = setTimeout(() => setCopied(false), 1800); }); } return [copied, copy]; } function PublicBooking() { const [config, setConfig] = useState(null); const [month, setMonth] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(''); const [slots, setSlots] = useState([]); const [selectedSlot, setSelectedSlot] = useState(null); const [duration, setDuration] = useState(null); const [meetingMode, setMeetingMode] = useState('google_meet'); const [loadingSlots, setLoadingSlots] = useState(false); const [booking, setBooking] = useState(false); const [error, setError] = useState(''); const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string }>({}); const [availabilityNotice, setAvailabilityNotice] = useState(''); const [confirmation, setConfirmation] = useState(null); const [paymentReturnState, setPaymentReturnState] = useState<{ kind: 'checking' | 'cancelled' | 'error'; message: string } | null>(null); const [manageOpen, setManageOpen] = useState(false); const [manageBookingId, setManageBookingId] = useState(''); const [managedBooking, setManagedBooking] = useState(null); const [manageAction, setManageAction] = useState('none'); const [manageDate, setManageDate] = useState(''); const [manageSlots, setManageSlots] = useState>([]); const [manageStart, setManageStart] = useState(''); const [manageSlotsLoading, setManageSlotsLoading] = useState(false); const [manageState, setManageState] = useState<{ kind: 'idle' | 'loading' | 'success' | 'error'; message: string }>({ kind: 'idle', message: '' }); useEffect(() => { api('/api/config') .then((data) => { setConfig(data); setMeetingMode(data.defaultMeetingMode); document.title = data.title; }) .catch((err) => setError(err.message)); }, []); useEffect(() => { const params = new URLSearchParams(window.location.search); const payment = params.get('payment'); const sessionId = params.get('session_id'); const bookingId = params.get('booking_id'); const released = params.get('released'); let cancelled = false; let timer: ReturnType | undefined; function cleanReturnUrl() { window.history.replaceState({}, '', window.location.pathname); } async function pollStatus(attempt = 0) { if (!sessionId || cancelled) return; setPaymentReturnState({ kind: 'checking', message: attempt === 0 ? 'Payment received. Confirming your booking…' : 'Payment received. Finalizing your calendar invitation…' }); try { const data = await api<{ state: string; message?: string; booking?: Confirmation }>(`/api/payments/status?session_id=${encodeURIComponent(sessionId)}`); if (cancelled) return; if (data.state === 'confirmed' && data.booking) { setConfirmation(data.booking); setMeetingMode(data.booking.meetingMode); setPaymentReturnState(null); cleanReturnUrl(); return; } if ((data.state === 'confirming' || data.state === 'processing') && attempt < 20) { setPaymentReturnState({ kind: 'checking', message: data.message || 'Finalizing your booking…' }); timer = setTimeout(() => void pollStatus(attempt + 1), 1000); return; } setPaymentReturnState({ kind: 'error', message: data.message || 'Payment did not complete, so no booking was confirmed.' }); cleanReturnUrl(); } catch (err) { if (cancelled) return; if (attempt < 8) { timer = setTimeout(() => void pollStatus(attempt + 1), 1200); return; } setPaymentReturnState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not verify the payment.' }); } } async function releaseCancelledCheckout() { if (!bookingId || cancelled) return; setPaymentReturnState({ kind: 'checking', message: 'Cancelling payment and releasing the time…' }); try { const data = await api<{ state: string; booking?: Confirmation }>('/api/payments/cancel', { method: 'POST', body: JSON.stringify({ bookingId }), }); if (cancelled) return; if (data.state === 'confirmed' && data.booking) { setConfirmation(data.booking); setMeetingMode(data.booking.meetingMode); setPaymentReturnState(null); } else if (data.state === 'processing') { setPaymentReturnState({ kind: 'checking', message: 'Stripe is processing the payment. Please wait…' }); } else { setPaymentReturnState({ kind: 'cancelled', message: 'Payment cancelled. No booking was confirmed and the time has been released.' }); } cleanReturnUrl(); } catch (err) { if (!cancelled) setPaymentReturnState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not cancel the checkout cleanly.' }); } } if (payment === 'success' && sessionId) void pollStatus(); else if (payment === 'cancelled' && released === '1') { setPaymentReturnState({ kind: 'cancelled', message: 'Payment cancelled. No booking was confirmed and the time has been released.' }); cleanReturnUrl(); } else if (payment === 'cancelled' && bookingId) void releaseCancelledCheckout(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, []); const cells = useMemo(() => monthCells(month), [month]); const today = config ? todayInZone(config.timezone) : dateKey(new Date()); const maxDate = config ? dateKey(addDays(parseDateKey(today), config.bookingWindowDays)) : today; const isSelectable = (key: string, weekday: number, current: boolean) => Boolean(config) && current && key >= today && key <= maxDate && config!.availableWeekdays.includes(weekday); async function chooseDate(key: string) { if (!config || key < today || key > maxDate) return; setSelectedDate(key); setSelectedSlot(null); setDuration(null); setConfirmation(null); setSlots([]); setError(''); setAvailabilityNotice(''); setLoadingSlots(true); try { const data = await api<{ slots: Slot[]; calendarError?: string }>(`/api/availability?date=${encodeURIComponent(key)}`); setSlots(data.slots); setAvailabilityNotice(data.calendarError ?? ''); } catch (err) { const message = err instanceof Error ? err.message : 'Could not load availability'; if (message.startsWith('Calendar setup needs attention')) setAvailabilityNotice(message); else setError(message); } finally { setLoadingSlots(false); } } async function submitBooking(event: FormEvent) { event.preventDefault(); if (!config || !selectedSlot || !duration) return; const form = new FormData(event.currentTarget); const name = String(form.get('name') ?? '').trim(); const email = String(form.get('email') ?? '').trim(); const nextErrors: { name?: string; email?: string } = {}; if (!name) nextErrors.name = 'Please add your name.'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) nextErrors.email = 'Enter a valid email address.'; setFieldErrors(nextErrors); if (Object.keys(nextErrors).length) return; setBooking(true); setError(''); try { const data = await api<{ requiresPayment: boolean; checkoutUrl?: string; booking?: Confirmation }>('/api/book', { method: 'POST', body: JSON.stringify({ start: selectedSlot.start, duration, meetingMode, name, email, phone: form.get('phone'), message: form.get('message'), website: form.get('website'), }), }); if (data.requiresPayment) { if (!data.checkoutUrl) throw new Error('Stripe did not return a secure checkout link.'); window.location.assign(data.checkoutUrl); return; } if (!data.booking) throw new Error('The booking response was incomplete.'); setConfirmation(data.booking); } catch (err) { const message = err instanceof Error ? err.message : 'Booking failed'; setError(message); if (message.toLowerCase().includes('slot') || message.toLowerCase().includes('time')) { await chooseDate(selectedDate); } } finally { setBooking(false); } } async function submitManageLookup(event: FormEvent) { event.preventDefault(); const bookingId = manageBookingId.trim(); if (!bookingId) { setManageState({ kind: 'error', message: 'Enter the booking ID from your confirmation or calendar invitation.' }); return; } setManageState({ kind: 'loading', message: 'Loading booking…' }); setManagedBooking(null); setManageAction('none'); setManageSlots([]); setManageStart(''); try { const result = await api<{ booking: ManagedBooking }>('/api/bookings/manage', { method: 'POST', body: JSON.stringify({ bookingId }), }); setManagedBooking(result.booking); setManageBookingId(result.booking.bookingId); setManageDate(dateKeyForInstant(result.booking.start, result.booking.timezone)); setManageState({ kind: 'idle', message: '' }); } catch (err) { setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not find this booking.' }); } } async function loadManageAvailability(date: string) { if (!managedBooking || !date) return; setManageDate(date); setManageStart(''); setManageSlots([]); setManageSlotsLoading(true); setManageState({ kind: 'idle', message: '' }); try { const result = await api<{ slots: Array<{ start: string; time: string }> }>('/api/bookings/manage/availability', { method: 'POST', body: JSON.stringify({ bookingId: managedBooking.bookingId, date }), }); setManageSlots(result.slots); } catch (err) { setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not load available times.' }); } finally { setManageSlotsLoading(false); } } async function cancelManagedBooking() { if (!managedBooking) return; setManageState({ kind: 'loading', message: 'Cancelling booking…' }); try { const result = await api<{ ok: boolean; refunded?: boolean; refundStatus?: string; alreadyCancelled?: boolean; warning?: string }>('/api/bookings/cancel', { method: 'POST', body: JSON.stringify({ bookingId: managedBooking.bookingId }), }); let message = result.alreadyCancelled ? 'This booking was already cancelled.' : result.refunded ? result.refundStatus === 'pending' ? 'Booking cancelled. Stripe accepted the refund and it is currently pending.' : 'Booking cancelled. The Stripe payment has been refunded.' : 'Booking cancelled. The calendar invitation has been cancelled.'; if (result.warning) message += ` ${result.warning}`; setManagedBooking({ ...managedBooking, status: 'cancelled', canCancel: false, canReschedule: false, refundStatus: result.refundStatus ?? managedBooking.refundStatus }); setManageAction('none'); setManageState({ kind: 'success', message }); if (confirmation?.bookingId && confirmation.bookingId.toUpperCase() === managedBooking.bookingId.toUpperCase()) setConfirmation(null); if (selectedDate) await chooseDate(selectedDate); } catch (err) { setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not cancel this booking.' }); } } async function rescheduleManagedBooking() { if (!managedBooking || !manageStart) return; setManageState({ kind: 'loading', message: 'Rescheduling booking…' }); try { const result = await api<{ ok: boolean; booking: ManagedBooking }>('/api/bookings/reschedule', { method: 'POST', body: JSON.stringify({ bookingId: managedBooking.bookingId, start: manageStart }), }); setManagedBooking(result.booking); setManageDate(dateKeyForInstant(result.booking.start, result.booking.timezone)); setManageStart(''); setManageSlots([]); setManageAction('none'); setManageState({ kind: 'success', message: 'Booking rescheduled. Google Calendar sent an updated invitation.' }); if (selectedDate) await chooseDate(selectedDate); } catch (err) { setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not reschedule this booking.' }); } } if (!config) { return
Loading calendar…
; } const activeConfig = config; const bookingStep = confirmation ? 'confirmed' : !selectedSlot ? 'time' : !duration ? 'duration' : 'details'; function changeTime() { setSelectedSlot(null); setDuration(null); setConfirmation(null); } function changeDuration() { setDuration(null); setConfirmation(null); } function bookAnotherTime() { setSelectedSlot(null); setDuration(null); setConfirmation(null); setFieldErrors({}); setError(''); } function renderTimesView() { const groups: Array<{ label: string; items: Slot[] }> = [ { label: 'Morning', items: [] }, { label: 'Afternoon', items: [] }, { label: 'Evening', items: [] }, ]; for (const slot of slots) { const hour = Number(slot.time.slice(0, 2)); const bucket = hour < 12 ? 0 : hour < 17 ? 1 : 2; groups[bucket].items.push(slot); } const nonEmpty = groups.filter((group) => group.items.length); const showLabels = slots.length > 6 && nonEmpty.length > 1; return (

02 · PICK A TIME

{prettyDate(selectedDate, activeConfig.timezone)}

{loadingSlots ? ( <>
Checking availability…
) : availabilityNotice ? (
{availabilityNotice}
) : slots.length === 0 ? (
Nothing open on this day. Try another date.
) : (
{nonEmpty.map((group) => (
{showLabels && {group.label}}
{group.items.map((slot) => ( ))}
))}
)}
); } function renderDurationView() { if (!selectedSlot) return null; const available = new Set(selectedSlot.durations); const disabledDurations = activeConfig.durations.filter((minutes) => !available.has(minutes)); const hasDisabled = disabledDurations.length > 0; const disabledReasons = new Set(disabledDurations.map((minutes) => selectedSlot.blockedDurations?.[String(minutes)] ?? 'unavailable')); const disabledHint = disabledReasons.size === 1 && disabledReasons.has('outside_hours') ? "Greyed-out lengths extend past today's booking hours." : disabledReasons.size === 1 && disabledReasons.has('unavailable') ? 'Greyed-out lengths run into unavailable calendar time, an availability block, or a configured buffer.' : "Greyed-out lengths either extend past today's booking hours or run into unavailable time."; return (
{prettyDate(selectedDate, activeConfig.timezone)} {selectedSlot.time}

03 · HOW LONG?

{activeConfig.durations.map((minutes) => { const enabled = available.has(minutes); return ( ); })}
{hasDisabled && (

{disabledHint}

)}
); } function renderDetailsView() { if (!selectedSlot || !duration) return null; const paymentRequiredForSelection = Boolean(activeConfig.payment && !activeConfig.payment.freeDurations.includes(duration)); return (
{prettyDate(selectedDate, activeConfig.timezone)} {selectedSlot.time} · {duration} min

04 · YOUR DETAILS

{activeConfig.allowMeetingModes.length > 1 && (
Meeting
{activeConfig.allowMeetingModes.map((mode) => { const Icon = modeMeta[mode].icon; return ( ); })}
)} {meetingMode === 'phone' && ( )}