public
imalexnord
read
Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Languages
Repository composition by tracked source files.
TypeScript
79%
CSS
19%
SQL
2%
HTML
0%
Create file
Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
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<string, DurationBlockReason>;
};
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<string, Array<{ start: string; end: string }>>;
};
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<string, unknown>;
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<MeetingMode, { label: string; icon: typeof Video; help: string }> = {
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<T>(url: string, init?: RequestInit): Promise<T> {
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<ReturnType<typeof setTimeout> | 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<PublicConfig | null>(null);
const [month, setMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState<string>('');
const [slots, setSlots] = useState<Slot[]>([]);
const [selectedSlot, setSelectedSlot] = useState<Slot | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [meetingMode, setMeetingMode] = useState<MeetingMode>('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<Confirmation | null>(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<ManagedBooking | null>(null);
const [manageAction, setManageAction] = useState<ManageAction>('none');
const [manageDate, setManageDate] = useState('');
const [manageSlots, setManageSlots] = useState<Array<{ start: string; time: string }>>([]);
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<PublicConfig>('/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<typeof setTimeout> | 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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 <div className="center-screen"><Loader2 className="spin" /> Loading calendar…</div>;
}
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 (
<div className="panel-view" key="times">
<div className="selection-heading">
<p className="step-label">02 · PICK A TIME</p>
<h2>{prettyDate(selectedDate, activeConfig.timezone)}</h2>
</div>
{loadingSlots ? (
<>
<div className="loading-row"><Loader2 className="spin" /> Checking availability…</div>
<div className="slot-skeleton" aria-hidden="true">
{Array.from({ length: 9 }, (_, i) => <span key={i} />)}
</div>
</>
) : availabilityNotice ? (
<div className="no-slots" role="status">{availabilityNotice}</div>
) : slots.length === 0 ? (
<div className="no-slots" role="status">Nothing open on this day. Try another date.</div>
) : (
<div className="time-scroll">
{nonEmpty.map((group) => (
<div className="time-group" key={group.label}>
{showLabels && <span className="time-group-label">{group.label}</span>}
<div className="time-grid">
{group.items.map((slot) => (
<button
key={slot.start}
type="button"
className={`time-button ${selectedSlot?.start === slot.start ? 'active' : ''}`}
aria-pressed={selectedSlot?.start === slot.start}
onClick={() => {
setSelectedSlot(slot);
setDuration(null);
setConfirmation(null);
}}
>
{slot.time}
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
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 (
<div className="panel-view" key="duration">
<button className="panel-back" type="button" onClick={changeTime}>
<ArrowLeft size={16} /> Change time
</button>
<div className="selected-summary">
<span>{prettyDate(selectedDate, activeConfig.timezone)}</span>
<strong>{selectedSlot.time}</strong>
</div>
<div className="duration-picker-view">
<p className="step-label">03 · HOW LONG?</p>
<div className="duration-grid">
{activeConfig.durations.map((minutes) => {
const enabled = available.has(minutes);
return (
<button
key={minutes}
type="button"
className={`duration-button ${duration === minutes ? 'active' : ''}`}
disabled={!enabled}
aria-pressed={duration === minutes}
title={enabled ? undefined : (selectedSlot.blockedDurations?.[String(minutes)] === 'outside_hours' ? "Extends past today's booking hours" : 'Runs into unavailable calendar time or a configured block')}
onClick={() => setDuration(minutes)}
>
<strong>{minutes}</strong>
<span>min</span>
</button>
);
})}
</div>
{hasDisabled && (
<p className="duration-hint">{disabledHint}</p>
)}
</div>
</div>
);
}
function renderDetailsView() {
if (!selectedSlot || !duration) return null;
const paymentRequiredForSelection = Boolean(activeConfig.payment && !activeConfig.payment.freeDurations.includes(duration));
return (
<div className="panel-view panel-view-scroll" key="details">
<button className="panel-back" type="button" onClick={changeDuration}>
<ArrowLeft size={16} /> Change duration
</button>
<div className="selected-summary">
<span>{prettyDate(selectedDate, activeConfig.timezone)}</span>
<strong>{selectedSlot.time} · {duration} min</strong>
</div>
<form className="details-form compact-details" onSubmit={submitBooking} noValidate>
<p className="step-label">04 · YOUR DETAILS</p>
<div className="form-row">
<label>Name
<input
name="name"
required
autoComplete="name"
placeholder="Your name"
aria-invalid={fieldErrors.name ? 'true' : undefined}
aria-describedby={fieldErrors.name ? 'name-error' : undefined}
onInput={() => fieldErrors.name && setFieldErrors((e) => ({ ...e, name: undefined }))}
/>
{fieldErrors.name && <span className="field-error" id="name-error">{fieldErrors.name}</span>}
</label>
<label>Email
<input
name="email"
type="email"
required
autoComplete="email"
placeholder="[email protected]"
aria-invalid={fieldErrors.email ? 'true' : undefined}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
onInput={() => fieldErrors.email && setFieldErrors((e) => ({ ...e, email: undefined }))}
/>
{fieldErrors.email && <span className="field-error" id="email-error">{fieldErrors.email}</span>}
</label>
</div>
{activeConfig.allowMeetingModes.length > 1 && (
<div className="mode-picker">
<span className="field-label">Meeting</span>
<div className="mode-options" role="radiogroup" aria-label="Meeting method">
{activeConfig.allowMeetingModes.map((mode) => {
const Icon = modeMeta[mode].icon;
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={meetingMode === mode}
className={`mode-option ${meetingMode === mode ? 'active' : ''}`}
onClick={() => setMeetingMode(mode)}
>
<Icon size={18} />
<span><strong>{modeMeta[mode].label}</strong><small>{modeMeta[mode].help}</small></span>
</button>
);
})}
</div>
</div>
)}
{meetingMode === 'phone' && (
<label>Phone number<input name="phone" required autoComplete="tel" placeholder="+46 ..." /></label>
)}
<label>Anything I should know? <span className="optional">Optional</span>
<textarea name="message" rows={3} placeholder="Context, topic, links..." />
</label>
{activeConfig.payment && paymentRequiredForSelection && (
<div className={`payment-note ${activeConfig.payment.ready ? '' : 'payment-unavailable'}`}>
<CreditCard size={17} />
<div>
<strong>{activeConfig.payment.ready ? 'Payment required to confirm' : 'Payments are temporarily unavailable'}</strong>
<span>
{activeConfig.payment.ready
? <>{formatMoneyMajor(activeConfig.payment.ratePerMinute, activeConfig.payment.currency)} per minute · {duration} min = <strong>{formatMoneyMajor(activeConfig.payment.ratePerMinute * duration, activeConfig.payment.currency)}</strong>. Your calendar invitation is sent only after Stripe confirms payment.</>
: <>This session requires payment, but its Stripe connection is not ready. Please contact the owner.</>}
</span>
</div>
</div>
)}
{activeConfig.payment && !paymentRequiredForSelection && (
<div className="payment-note free-session-note">
<Check size={17} />
<div><strong>No payment required</strong><span>{duration}-minute sessions are configured as free.</span></div>
</div>
)}
<label className="honeypot" aria-hidden="true">Website<input name="website" tabIndex={-1} autoComplete="off" /></label>
{error && <div className="error-banner" role="alert">{error}</div>}
<button className="primary-button" type="submit" disabled={booking || Boolean(paymentRequiredForSelection && activeConfig.payment && !activeConfig.payment.ready)}>
{booking
? <><Loader2 className="spin" size={18} /> {paymentRequiredForSelection ? 'Opening payment…' : 'Booking…'}</>
: <>{paymentRequiredForSelection ? (activeConfig.payment?.ready ? 'Continue to secure payment' : 'Payment unavailable') : 'Book this slot'} <ArrowRight size={18} /></>}
</button>
</form>
</div>
);
}
function renderConfirmationView() {
if (!confirmation) return null;
return <ConfirmationView title={activeConfig.title} confirmation={confirmation} onBookAnother={bookAnotherTime} />;
}
function renderRightPanel() {
if (confirmation) return renderConfirmationView();
if (paymentReturnState) {
return (
<div className="panel-view payment-return-panel" key="payment-return">
{paymentReturnState.kind === 'checking' ? <Loader2 className="spin" size={28} /> : paymentReturnState.kind === 'cancelled' ? <CreditCard size={28} /> : <Inbox size={28} />}
<p className="eyebrow">PAYMENT</p>
<h2>{paymentReturnState.kind === 'checking' ? 'Almost done.' : paymentReturnState.kind === 'cancelled' ? 'Payment cancelled.' : 'Booking not confirmed.'}</h2>
<p className="muted">{paymentReturnState.message}</p>
{paymentReturnState.kind !== 'checking' && <button className="text-button" type="button" onClick={() => setPaymentReturnState(null)}>Choose another time</button>}
</div>
);
}
if (!activeConfig.connected) {
return (
<div className="empty-state" key="disconnected">
<MonitorUp size={28} />
<h3>Calendar is not connected yet</h3>
<p>The owner needs to connect Google Calendar before times can be booked.</p>
</div>
);
}
if (!selectedDate) {
return (
<div className="empty-state subtle-empty" key="empty">
<ArrowLeft size={28} />
<h3>Start with the calendar</h3>
<p>Pick a date and available times will appear here.</p>
</div>
);
}
if (bookingStep === 'duration') return renderDurationView();
if (bookingStep === 'details') return renderDetailsView();
return renderTimesView();
}
return (
<main className="booking-page">
<header className="topbar">
<span className="timezone-pill"><Clock3 size={14} /> {activeConfig.timezone}</span>
</header>
<section className="booking-shell">
<aside className="intro-panel">
<div>
<p className="eyebrow">SCHEDULE A MEETING</p>
<h1>{activeConfig.title}</h1>
<p className="intro-copy">{activeConfig.subtitle}</p>
</div>
<div className="intro-bottom">
<div className="intro-note">
<CalendarDays size={18} />
<div>
<strong>Your calendar, not a form maze.</strong>
<span>Choose the day first. Everything else follows.</span>
</div>
</div>
<div className={`public-manage ${manageOpen ? 'open' : ''}`}>
{!manageOpen ? (
<button
className="public-manage-link"
type="button"
onClick={() => {
setManageOpen(true);
setManageState({ kind: 'idle', message: '' });
}}
>Manage a booking</button>
) : !managedBooking ? (
<form className="public-manage-form" onSubmit={submitManageLookup}>
<div className="public-manage-heading">
<strong>Manage booking</strong>
<button className="text-button" type="button" onClick={() => setManageOpen(false)}>Close</button>
</div>
<label>
Booking ID
<input
value={manageBookingId}
onChange={(event) => {
setManageBookingId(event.target.value.toUpperCase());
if (manageState.kind !== 'loading') setManageState({ kind: 'idle', message: '' });
}}
autoComplete="off"
spellCheck={false}
placeholder="SLT-7K4Q-9M2P-W8RX-3FJN"
aria-label="Booking ID"
/>
</label>
<button className="secondary-button public-manage-submit" type="submit" disabled={manageState.kind === 'loading'}>
{manageState.kind === 'loading' ? <><Loader2 className="spin" size={15} /> Loading…</> : 'Manage booking'}
</button>
<small>Your booking ID is shown on the confirmation screen and in the calendar invitation.</small>
{manageState.kind === 'error' && <p className="public-manage-message error" role="alert">{manageState.message}</p>}
</form>
) : (
<div className="public-manage-form">
<div className="public-manage-heading">
<strong>Manage booking</strong>
<button className="text-button" type="button" onClick={() => {
setManagedBooking(null);
setManageAction('none');
setManageState({ kind: 'idle', message: '' });
}}>Another ID</button>
</div>
<div className="public-manage-summary">
<code>{managedBooking.bookingId}</code>
<strong>{managedBooking.name}</strong>
<span>{formatDateShort(managedBooking.start, managedBooking.timezone)} · {formatClock(managedBooking.start, managedBooking.timezone)} · {managedBooking.duration} min</span>
{managedBooking.status !== 'confirmed' && <span className="public-manage-status">{managedBooking.status === 'cancelled' ? 'Cancelled' : managedBooking.status}</span>}
</div>
{manageAction === 'none' && managedBooking.status === 'confirmed' && (
<div className="public-manage-actions">
<button type="button" className="secondary-button" disabled={!managedBooking.canReschedule} onClick={() => {
setManageAction('reschedule');
const date = dateKeyForInstant(managedBooking.start, managedBooking.timezone);
void loadManageAvailability(date);
}}>Reschedule</button>
<button type="button" className="danger-button" disabled={!managedBooking.canCancel} onClick={() => setManageAction('cancel')}>Cancel</button>
</div>
)}
{manageAction === 'cancel' && (
<div className="public-manage-action-panel">
<strong>Cancel this booking?</strong>
<small>{managedBooking.paymentStatus === 'paid' ? 'The Stripe payment will be refunded to the original payment method before the meeting is cancelled.' : 'The calendar invitation will be cancelled and the time will become available again.'}</small>
<div className="public-manage-actions">
<button type="button" className="secondary-button" onClick={() => setManageAction('none')} disabled={manageState.kind === 'loading'}>Keep booking</button>
<button type="button" className="danger-button" onClick={cancelManagedBooking} disabled={manageState.kind === 'loading'}>{manageState.kind === 'loading' ? 'Cancelling…' : managedBooking.paymentStatus === 'paid' ? 'Cancel & refund' : 'Confirm cancellation'}</button>
</div>
</div>
)}
{manageAction === 'reschedule' && (
<div className="public-manage-action-panel">
<label>New date<input type="date" value={manageDate} onChange={(event) => void loadManageAvailability(event.target.value)} /></label>
{manageSlotsLoading ? <div className="public-manage-loading"><Loader2 className="spin" size={14} /> Checking times…</div> : manageSlots.length ? (
<div className="public-manage-times">
{manageSlots.map((slot) => <button type="button" key={slot.start} className={manageStart === slot.start ? 'active' : ''} onClick={() => setManageStart(slot.start)}>{slot.time}</button>)}
</div>
) : <small>No matching {managedBooking.duration}-minute times are open on this date.</small>}
<div className="public-manage-actions">
<button type="button" className="secondary-button" onClick={() => { setManageAction('none'); setManageStart(''); }} disabled={manageState.kind === 'loading'}>Back</button>
<button type="button" className="primary-button" onClick={rescheduleManagedBooking} disabled={!manageStart || manageState.kind === 'loading'}>{manageState.kind === 'loading' ? 'Rescheduling…' : 'Reschedule booking'}</button>
</div>
</div>
)}
{manageState.kind === 'success' && <p className="public-manage-message success" role="status">{manageState.message}</p>}
{manageState.kind === 'error' && <p className="public-manage-message error" role="alert">{manageState.message}</p>}
</div>
)}
</div>
</div>
</aside>
<section className="calendar-panel" aria-label="Choose a date">
<div className="calendar-toolbar">
<div>
<p className="step-label">01 · PICK A DAY</p>
<h2 aria-live="polite">{month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</h2>
</div>
<div className="month-buttons">
<button type="button" aria-label="Previous month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><ChevronLeft /></button>
<button type="button" aria-label="Next month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><ChevronRight /></button>
</div>
</div>
<div className="weekday-grid" aria-hidden="true">
{shortDayNames.map((name) => <span key={name}>{name}</span>)}
</div>
<div className="calendar-grid">
{cells.map(({ date, current }) => {
const key = dateKey(date);
const selectable = isSelectable(key, date.getDay(), current);
const selected = key === selectedDate;
const isToday = key === today;
return (
<button
key={key}
type="button"
className={`day ${current ? '' : 'outside'} ${selected ? 'selected' : ''} ${isToday ? 'today' : ''}`}
disabled={!selectable}
aria-pressed={selected}
aria-label={dayAriaLabel(date)}
onClick={() => chooseDate(key)}
>
<span>{date.getDate()}</span>
{isToday && <small>Today</small>}
</button>
);
})}
</div>
</section>
<aside className="selection-panel" aria-live="polite">{renderRightPanel()}</aside>
</section>
{error && !(selectedSlot && duration) && <div className="floating-error" role="alert">{error}</div>}
<footer>
<a className="powered-by" href="https://www.nobgit.com/user/imalexnord/slot" target="_blank" rel="noreferrer">Powered by Slot</a>
</footer>
</main>
);
}
function ConfirmationView({ title, confirmation, onBookAnother }: { title: string; confirmation: Confirmation; onBookAnother: () => void }) {
const [copied, copy] = useCopy();
const tz = confirmation.timezone;
return (
<div className="panel-view confirmation-panel panel-view-scroll" key="confirmed">
<span className="success-icon"><Check /></span>
<p className="eyebrow">YOU'RE BOOKED</p>
<h2>See you then.</h2>
<dl className="confirmation-details">
<div className="confirm-row"><dt>Date</dt><dd>{formatDateShort(confirmation.start, tz)}</dd></div>
<div className="confirm-row"><dt>Time</dt><dd>{formatClock(confirmation.start, tz)} – {formatClock(confirmation.end, tz)}</dd></div>
<div className="confirm-row"><dt>Length</dt><dd>{confirmation.duration} min</dd></div>
{confirmation.bookingId && <div className="confirm-row booking-id-row"><dt>Booking ID</dt><dd>{confirmation.bookingId}</dd></div>}
<div className="confirm-row"><dt>Timezone</dt><dd>{tz}</dd></div>
<div className="confirm-row"><dt>Meeting</dt><dd>{modeMeta[confirmation.meetingMode].label}</dd></div>
</dl>
<p className="muted">Your booking is confirmed. A calendar invitation has been sent to your email.</p>
<div className="confirm-actions">
<a className="primary-button" href={googleCalendarUrl(title, confirmation)} target="_blank" rel="noreferrer">
<CalendarPlus size={16} /> Add to calendar
</a>
{confirmation.receiptUrl && (
<a className="secondary-button" href={confirmation.receiptUrl} target="_blank" rel="noreferrer">
View Stripe receipt <ExternalLink size={15} />
</a>
)}
{confirmation.meetUrl && (
<>
<a className="secondary-button" href={confirmation.meetUrl} target="_blank" rel="noreferrer">
Open Google Meet <ExternalLink size={15} />
</a>
<button
type="button"
className={`copy-button ${copied ? 'copied' : ''}`}
onClick={() => copy(confirmation.meetUrl!)}
>
{copied ? <><Check size={15} /> Link copied</> : <><Copy size={15} /> Copy Meet link</>}
</button>
</>
)}
<button className="text-button" type="button" onClick={onBookAnother}>Book another time</button>
</div>
</div>
);
}
function RescheduleSuggestionPage() {
const token = decodeURIComponent(window.location.pathname.split('/').filter(Boolean)[1] ?? '');
const [proposal, setProposal] = useState<RescheduleProposal | null>(null);
const [accepted, setAccepted] = useState<Confirmation | null>(null);
const [loading, setLoading] = useState(true);
const [accepting, setAccepting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
api<RescheduleProposal>(`/api/reschedule-suggestions/${encodeURIComponent(token)}`)
.then((data) => {
setProposal(data);
document.title = `Reschedule · ${data.title}`;
})
.catch((err) => setError(err instanceof Error ? err.message : 'Could not load this suggestion.'))
.finally(() => setLoading(false));
}, [token]);
async function acceptSuggestion() {
setAccepting(true);
setError('');
try {
const result = await api<{ booking: Confirmation }>(`/api/reschedule-suggestions/${encodeURIComponent(token)}/accept`, {
method: 'POST',
});
setAccepted(result.booking);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not accept the new time.');
} finally {
setAccepting(false);
}
}
if (loading) return <div className="center-screen"><Loader2 className="spin" /> Loading suggestion…</div>;
if (accepted && proposal) {
return (
<main className="admin-login-shell">
<section className="admin-login-card reschedule-public-card">
<ConfirmationView title={proposal.title} confirmation={accepted} onBookAnother={() => { window.location.href = '/'; }} />
</section>
</main>
);
}
if (!proposal) {
return (
<main className="admin-login-shell">
<section className="admin-login-card">
<span className="admin-login-icon"><CalendarDays /></span>
<p className="eyebrow">RESCHEDULE</p>
<h1>Suggestion unavailable.</h1>
<p>{error || 'This reschedule link has expired or was already used.'}</p>
</section>
</main>
);
}
return (
<main className="admin-login-shell">
<section className="admin-login-card reschedule-public-card">
<span className="admin-login-icon"><CalendarDays /></span>
<p className="eyebrow">NEW TIME SUGGESTED</p>
<h1>Does this time work?</h1>
<p>Hi {proposal.name}. The organizer suggested moving your meeting. Your current time stays booked until you accept.</p>
<div className="schedule-compare">
<div>
<span>Current</span>
<strong>{formatDateShort(proposal.currentStart, proposal.timezone)}</strong>
<small>{formatClock(proposal.currentStart, proposal.timezone)} – {formatClock(proposal.currentEnd, proposal.timezone)}</small>
</div>
<ArrowRight size={18} />
<div className="proposed">
<span>Suggested</span>
<strong>{formatDateShort(proposal.proposedStart, proposal.timezone)}</strong>
<small>{formatClock(proposal.proposedStart, proposal.timezone)} – {formatClock(proposal.proposedEnd, proposal.timezone)}</small>
</div>
</div>
<p className="muted">{proposal.duration} min · {proposal.timezone} · {modeMeta[proposal.meetingMode].label}</p>
{error && <div className="error-banner" role="alert">{error}</div>}
<button className="primary-button inline-button" type="button" onClick={acceptSuggestion} disabled={accepting}>
{accepting ? <><Loader2 className="spin" size={17} /> Rescheduling…</> : <>Accept new time <ArrowRight size={17} /></>}
</button>
</section>
</main>
);
}
function BookingRowItem({
booking,
timezone,
onOpen,
}: {
booking: BookingRow;
timezone: string;
onOpen: (booking: BookingRow) => void;
}) {
const [copied, copy] = useCopy();
const isCancelled = booking.status === 'cancelled';
const isPaymentHold = booking.status === 'pending' && booking.payment_status !== 'paid';
const isPast = Date.parse(booking.end_time) < Date.now() || isCancelled;
const Icon = modeMeta[booking.meeting_mode].icon;
return (
<div className={`booking-list-row ${isPast ? 'past' : ''} ${isCancelled ? 'cancelled' : ''}`}>
<div className="booking-avatar" aria-hidden="true">{booking.name.slice(0, 1).toUpperCase()}</div>
<div className="booking-person">
<strong>{booking.name}</strong>
<span>{booking.email}</span>
</div>
<div className="booking-when">
<strong>{formatDateShort(booking.start_time, timezone)}</strong>
<span>{formatClock(booking.start_time, timezone)} – {formatClock(booking.end_time, timezone)}</span>
</div>
<div className="booking-meta">
<span className="booking-method"><Icon size={13} /> {modeMeta[booking.meeting_mode].label}</span>
<span>{booking.duration_minutes} min{booking.payment_status === 'paid' ? booking.refund_status === 'succeeded' ? ' · Refunded' : booking.refund_status === 'pending' ? ' · Refund pending' : ' · Paid' : isPaymentHold ? ` · Awaiting payment · ${checkoutSessionMode(booking.stripe_checkout_session_id).toUpperCase()}` : ''}</span>
</div>
<div className="booking-actions">
<span className={`status-pill ${isCancelled ? 'cancelled' : isPast ? 'past' : 'upcoming'}`}>
{isCancelled ? 'Cancelled' : isPaymentHold ? 'Payment hold' : isPast ? 'Past' : 'Upcoming'}
</span>
<button type="button" className="icon-button" aria-label="Open booking details" title="Open details" onClick={() => onOpen(booking)}>
<Inbox size={15} />
</button>
{booking.status === 'confirmed' && booking.meet_url && (
<>
<button
type="button"
className={`icon-button ${copied ? 'copied' : ''}`}
aria-label="Copy Meet link"
title="Copy Meet link"
onClick={() => copy(booking.meet_url!)}
>
{copied ? <Check size={15} /> : <Copy size={15} />}
</button>
<a className="icon-button" href={booking.meet_url} target="_blank" rel="noreferrer" aria-label="Open Google Meet" title="Open Google Meet">
<Video size={15} />
</a>
</>
)}
</div>
</div>
);
}
function Admin() {
const [session, setSession] = useState<AdminSession | null>(null);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [bookings, setBookings] = useState<BookingRow[]>([]);
const [recurringUnavailable, setRecurringUnavailable] = useState<RecurringUnavailableRule[]>([]);
const [availabilityBlocks, setAvailabilityBlocks] = useState<AvailabilityBlock[]>([]);
const [activity, setActivity] = useState<ActivityData | null>(null);
const [activityLoading, setActivityLoading] = useState(false);
const [attendeeData, setAttendeeData] = useState<AttendeeData | null>(null);
const [attendeesLoading, setAttendeesLoading] = useState(false);
const [attendeeSearch, setAttendeeSearch] = useState('');
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeRow | null>(null);
const [attendeeBookings, setAttendeeBookings] = useState<BookingRow[]>([]);
const [attendeeBookingsLoading, setAttendeeBookingsLoading] = useState(false);
const [tab, setTab] = useState<'settings' | 'bookings' | 'activity' | 'attendees'>('bookings');
const [bookingFilter, setBookingFilter] = useState<'upcoming' | 'past'>('upcoming');
const [bookingSearch, setBookingSearch] = useState('');
const [bookingSearchResults, setBookingSearchResults] = useState<BookingRow[] | null>(null);
const [bookingSearchLoading, setBookingSearchLoading] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<BookingRow | null>(null);
const [cancelTarget, setCancelTarget] = useState<BookingRow | null>(null);
const [cancelling, setCancelling] = useState(false);
const [scheduleTarget, setScheduleTarget] = useState<BookingRow | null>(null);
const [scheduleAction, setScheduleAction] = useState<BookingScheduleAction>('reschedule');
const [scheduleDate, setScheduleDate] = useState('');
const [scheduleSlots, setScheduleSlots] = useState<Slot[]>([]);
const [scheduleStart, setScheduleStart] = useState('');
const [loadingScheduleSlots, setLoadingScheduleSlots] = useState(false);
const [savingScheduleAction, setSavingScheduleAction] = useState(false);
const [suggestionUrl, setSuggestionUrl] = useState('');
const [scheduleLinkCopied, copyScheduleLink] = useCopy();
const [recurringForm, setRecurringForm] = useState({ weekdays: [1, 2, 3, 4, 5], start: '12:00', end: '13:00', label: 'Lunch' });
const [blockForm, setBlockForm] = useState({ date: '', start: '09:00', end: '12:00', allDay: false, label: '' });
const [theme, setTheme] = useState<ThemeId>(() => {
const stored = localStorage.getItem('meet-theme') as ThemeId | null;
return stored && themes.some((item) => item.id === stored) ? stored : 'true-black';
});
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [stripeConfigured, setStripeConfigured] = useState(false);
const [stripeMode, setStripeMode] = useState<'test' | 'live' | 'unknown'>('unknown');
useEffect(() => {
document.title = 'Bookings';
}, []);
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('meet-theme', theme);
}, [theme]);
useEffect(() => {
fetch('/api/admin/session')
.then(async (response) => {
if (!response.ok) throw new Error('unauthenticated');
return response.json() as Promise<AdminSession>;
})
.then((data) => setSession(data))
.catch(() => setSession({ authenticated: false, email: '', connected: false }));
}, []);
useEffect(() => {
if (!session?.authenticated) return;
Promise.all([
api<{ settings: AppSettings; stripeConfigured: boolean; stripeMode: 'test' | 'live' | 'unknown' }>('/api/admin/settings'),
api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
]).then(([settingsData, bookingData, availabilityData]) => {
setSettings(settingsData.settings);
setStripeConfigured(settingsData.stripeConfigured);
setStripeMode(settingsData.stripeMode);
setBookings(bookingData.bookings);
setRecurringUnavailable(availabilityData.recurring);
setAvailabilityBlocks(availabilityData.blocks);
}).catch((err) => setError(err.message));
}, [session]);
useEffect(() => {
if (!session?.authenticated || tab !== 'activity') return;
void loadActivity();
}, [session?.authenticated, tab]);
useEffect(() => {
if (!session?.authenticated || tab !== 'attendees') return;
void loadAttendees();
}, [session?.authenticated, tab]);
useEffect(() => {
if (!session?.authenticated || tab !== 'bookings') return;
const query = bookingSearch.trim();
if (!query) {
setBookingSearchResults(null);
setBookingSearchLoading(false);
return;
}
let cancelled = false;
setError('');
setBookingSearchResults(null);
const timer = window.setTimeout(() => {
setBookingSearchLoading(true);
api<{ bookings: BookingRow[] }>(`/api/admin/bookings?q=${encodeURIComponent(query)}`)
.then((data) => { if (!cancelled) setBookingSearchResults(data.bookings); })
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Could not search bookings.'); })
.finally(() => { if (!cancelled) setBookingSearchLoading(false); });
}, 180);
return () => { cancelled = true; window.clearTimeout(timer); };
}, [session?.authenticated, tab, bookingSearch]);
useEffect(() => {
if (!scheduleTarget || !scheduleDate) {
setScheduleSlots([]);
return;
}
let cancelled = false;
setLoadingScheduleSlots(true);
setScheduleStart('');
api<{ slots: Slot[] }>(`/api/admin/bookings/${scheduleTarget.id}/availability?date=${encodeURIComponent(scheduleDate)}`)
.then((data) => {
if (!cancelled) setScheduleSlots(data.slots);
})
.catch((err) => {
if (!cancelled) {
setScheduleSlots([]);
setError(err instanceof Error ? err.message : 'Could not load available times.');
}
})
.finally(() => {
if (!cancelled) setLoadingScheduleSlots(false);
});
return () => { cancelled = true; };
}, [scheduleTarget?.id, scheduleDate]);
function openScheduleDialog(booking: BookingRow, action: BookingScheduleAction) {
if (!settings) return;
setScheduleTarget(booking);
setScheduleAction(action);
setScheduleDate(dateKeyForInstant(booking.start_time, settings.timezone));
setScheduleStart('');
setScheduleSlots([]);
setSuggestionUrl('');
setError('');
}
async function submitScheduleAction() {
if (!scheduleTarget || !scheduleStart) return;
setSavingScheduleAction(true);
setError('');
try {
if (scheduleAction === 'reschedule') {
await api(`/api/admin/bookings/${scheduleTarget.id}/reschedule`, {
method: 'POST',
body: JSON.stringify({ start: scheduleStart }),
});
await refreshAdminData();
setScheduleTarget(null);
setSelectedBooking(null);
setNotice('Meeting rescheduled. Google Calendar sent the attendee an updated invitation.');
} else {
const result = await api<{ suggestionUrl: string }>(`/api/admin/bookings/${scheduleTarget.id}/suggest`, {
method: 'POST',
body: JSON.stringify({ start: scheduleStart }),
});
await refreshAdminData();
setSuggestionUrl(result.suggestionUrl);
setNotice('New time suggested. Google Calendar notified the attendee and the current meeting remains booked until they accept.');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update the meeting.');
} finally {
setSavingScheduleAction(false);
}
}
async function loadActivity() {
setActivityLoading(true);
setError('');
try {
setActivity(await api<ActivityData>('/api/admin/activity'));
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not load activity.');
} finally {
setActivityLoading(false);
}
}
async function loadAttendees() {
setAttendeesLoading(true);
setError('');
try {
setAttendeeData(await api<AttendeeData>('/api/admin/attendees'));
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not load attendees.');
} finally {
setAttendeesLoading(false);
}
}
async function openAttendee(attendee: AttendeeRow) {
setSelectedAttendee(attendee);
setAttendeeBookings([]);
setAttendeeBookingsLoading(true);
setError('');
try {
const data = await api<{ bookings: BookingRow[] }>(`/api/admin/attendee-bookings?email=${encodeURIComponent(attendee.email)}`);
setAttendeeBookings(data.bookings);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not load attendee history.');
} finally {
setAttendeeBookingsLoading(false);
}
}
function openBookingFromAttendee(booking: BookingRow) {
setTab('bookings');
setBookingSearch(booking.public_booking_id || booking.id);
setSelectedBooking(booking);
}
async function refreshAdminData() {
const query = bookingSearch.trim();
const [bookingData, availabilityData, searchedData] = await Promise.all([
api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
query ? api<{ bookings: BookingRow[] }>(`/api/admin/bookings?q=${encodeURIComponent(query)}`) : Promise.resolve(null),
]);
setBookings(bookingData.bookings);
if (searchedData) setBookingSearchResults(searchedData.bookings);
setRecurringUnavailable(availabilityData.recurring);
setAvailabilityBlocks(availabilityData.blocks);
if (attendeeData) void loadAttendees();
}
async function save() {
if (!settings) return;
setSaving(true);
setError('');
setNotice('');
try {
const data = await api<{ settings: AppSettings }>('/api/admin/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
setSettings(data.settings);
setNotice('Saved. The public calendar is using these settings now.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not save settings');
} finally {
setSaving(false);
}
}
async function addRecurringUnavailable() {
setError('');
try {
await api('/api/admin/availability-rules/recurring', {
method: 'POST',
body: JSON.stringify(recurringForm),
});
await refreshAdminData();
setNotice('Recurring unavailable time added.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not add unavailable time');
}
}
async function addAvailabilityBlock() {
setError('');
try {
await api('/api/admin/availability-rules/blocks', {
method: 'POST',
body: JSON.stringify(blockForm),
});
await refreshAdminData();
setBlockForm({ date: '', start: '09:00', end: '12:00', allDay: false, label: '' });
setNotice('Blocked time added.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not add blocked time');
}
}
async function deleteAvailabilityRule(kind: 'recurring' | 'block', id: string) {
setError('');
try {
await api(`/api/admin/availability-rules/${kind}/${id}`, { method: 'DELETE' });
await refreshAdminData();
setNotice('Availability rule removed.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not remove availability rule');
}
}
async function confirmCancelBooking() {
if (!cancelTarget) return;
const releasingPaymentHold = cancelTarget.status === 'pending';
setCancelling(true);
setError('');
try {
const result = await api<{ warning?: string; refunded?: boolean; refundStatus?: string }>(`/api/admin/bookings/${cancelTarget.id}/cancel`, {
method: 'POST',
body: JSON.stringify({ allowStaleStripeRelease: releasingPaymentHold }),
});
setCancelTarget(null);
setSelectedBooking(null);
await refreshAdminData();
setNotice(releasingPaymentHold
? result.warning
? `Payment hold released. The time is available again. ${result.warning}`
: 'Payment hold released. The time is available again.'
: result.refunded
? `Meeting cancelled and Stripe refund ${result.refundStatus === 'pending' ? 'started' : 'issued'}.${result.warning ? ` ${result.warning}` : ''}`
: `Meeting cancelled. Google Calendar will notify the attendee.${result.warning ? ` ${result.warning}` : ''}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not cancel meeting');
} finally {
setCancelling(false);
}
}
if (session === null) return <div className="center-screen"><Loader2 className="spin" /> Loading admin…</div>;
if (!session.authenticated) {
const params = new URLSearchParams(window.location.search);
const oauthError = params.get('oauth_error');
return (
<main className="admin-login-shell">
<section className="admin-login-card">
<span className="admin-login-icon"><Settings2 /></span>
<p className="eyebrow">PRIVATE</p>
<h1>Control your availability.</h1>
<p>Google login is only for the calendar owner. People booking you never need an account.</p>
{oauthError && <div className="error-banner" role="alert">{oauthError}</div>}
<a className="primary-button inline-button" href="/auth/google/start">Continue with Google <ArrowRight size={18} /></a>
<a className="text-button" href="/">Back to booking page</a>
</section>
</main>
);
}
if (!settings) return <div className="center-screen"><Loader2 className="spin" /> Loading settings…</div>;
function setDay(day: number, patch: Partial<{ enabled: boolean; start: string; end: string }>) {
setSettings((current) => {
if (!current) return current;
const existing = current.availability[String(day)]?.[0] ?? { start: '09:00', end: '17:00' };
const enabled = patch.enabled ?? Boolean(current.availability[String(day)]?.length);
return {
...current,
availability: {
...current.availability,
[String(day)]: enabled ? [{ start: patch.start ?? existing.start, end: patch.end ?? existing.end }] : [],
},
};
});
}
const activeSettings = settings;
const now = Date.now();
const bookingSource = bookingSearch.trim() ? (bookingSearchResults ?? []) : bookings;
const upcoming = bookingSource.filter((b) => b.status !== 'cancelled' && Date.parse(b.end_time) >= now);
const past = bookingSource.filter((b) => b.status === 'cancelled' || Date.parse(b.end_time) < now);
const shown = bookingFilter === 'upcoming' ? upcoming : past;
const attendeeNeedle = attendeeSearch.trim().toLowerCase();
const shownAttendees = (attendeeData?.attendees ?? []).filter((attendee) => !attendeeNeedle
|| attendee.name.toLowerCase().includes(attendeeNeedle)
|| attendee.email.toLowerCase().includes(attendeeNeedle));
return (
<main className="admin-page">
<header className="admin-topbar">
<div className="admin-user">
<span>{session.email}</span>
<button onClick={async () => { await api('/api/admin/logout', { method: 'POST' }); window.location.reload(); }}><LogOut size={16} /> Log out</button>
</div>
</header>
<div className="admin-layout">
<aside className="admin-nav">
<button className={tab === 'bookings' ? 'active' : ''} onClick={() => setTab('bookings')}><CalendarDays size={17} /> Bookings</button>
<button className={tab === 'settings' ? 'active' : ''} onClick={() => setTab('settings')}><Settings2 size={17} /> Settings</button>
<button className={tab === 'activity' ? 'active' : ''} onClick={() => setTab('activity')}><Activity size={17} /> Activity</button>
<button className={tab === 'attendees' ? 'active' : ''} onClick={() => setTab('attendees')}><Users size={17} /> Attendees</button>
<a href="/" target="_blank"><ExternalLink size={17} /> Open booking page</a>
</aside>
<section className="admin-content">
{tab === 'settings' ? (
<>
<div className="admin-heading">
<div><p className="eyebrow">SETTINGS</p><h1>Keep it simple.</h1></div>
<button className="primary-button compact" onClick={save} disabled={saving}>{saving ? <Loader2 className="spin" /> : <Check />} Save changes</button>
</div>
{notice && <div className="success-banner" role="status">{notice}</div>}
{error && <div className="error-banner" role="alert">{error}</div>}
<div className="settings-grid">
<section className="settings-card wide-card appearance-card">
<div className="appearance-heading">
<div>
<p className="eyebrow">APPEARANCE</p>
<h2>Theme and contrast</h2>
<p>Choose how this app looks on this browser. Your selection is saved locally and applied before each page is shown.</p>
</div>
<span>{themes.find((item) => item.id === theme)?.name} selected</span>
</div>
<div className="theme-grid">
{themes.map((item) => (
<button
type="button"
key={item.id}
className={`theme-option ${theme === item.id ? 'active' : ''}`}
aria-pressed={theme === item.id}
onClick={() => setTheme(item.id)}
>
<span className={`theme-preview theme-preview-${item.id}`} aria-hidden="true">
<span />
</span>
<span className="theme-copy">
<strong>{item.name}</strong>
<small>{item.description}</small>
<em>{item.contrast}</em>
</span>
</button>
))}
</div>
<p className="theme-footnote">Theme settings stay on this device and do not change your booking page for other visitors.</p>
</section>
<section className="settings-card wide-card">
<div className="card-heading"><h2>Availability</h2><p>Your Google Calendar blocks busy time inside these hours.</p></div>
<div className="availability-list">
{dayNames.map((name, day) => {
const interval = activeSettings.availability[String(day)]?.[0];
const enabled = Boolean(interval);
return (
<div className="availability-row" key={name}>
<label className="switch-row"><input type="checkbox" checked={enabled} onChange={(e) => setDay(day, { enabled: e.target.checked })} /><span>{name}</span></label>
{enabled ? (
<div className="time-range">
<input type="time" aria-label={`${name} start`} value={interval.start} onChange={(e) => setDay(day, { start: e.target.value })} />
<span>to</span>
<input type="time" aria-label={`${name} end`} value={interval.end} onChange={(e) => setDay(day, { end: e.target.value })} />
</div>
) : <span className="unavailable">Unavailable</span>}
</div>
);
})}
</div>
<div className="availability-tools">
<section className="availability-tool">
<div className="card-heading"><h3>Recurring unavailable</h3><p>Private blocks such as lunch or focus time.</p></div>
<div className="weekday-picker">
{dayNames.map((name, day) => (
<button
type="button"
key={name}
className={recurringForm.weekdays.includes(day) ? 'active' : ''}
onClick={() => setRecurringForm((current) => ({
...current,
weekdays: current.weekdays.includes(day)
? current.weekdays.filter((value) => value !== day)
: [...current.weekdays, day].sort((a, b) => a - b),
}))}
>
{name.slice(0, 3)}
</button>
))}
</div>
<div className="calendar-settings-row">
<label>From<input type="time" value={recurringForm.start} onChange={(e) => setRecurringForm({ ...recurringForm, start: e.target.value })} /></label>
<label>Until<input type="time" value={recurringForm.end} onChange={(e) => setRecurringForm({ ...recurringForm, end: e.target.value })} /></label>
</div>
<label>Private label<input value={recurringForm.label} onChange={(e) => setRecurringForm({ ...recurringForm, label: e.target.value })} placeholder="Lunch" /></label>
<button className="secondary-button" type="button" onClick={addRecurringUnavailable}>Add unavailable period</button>
<div className="rule-list">
{recurringUnavailable.length === 0 ? <span>No recurring unavailable periods.</span> : recurringUnavailable.map((rule) => (
<div className="rule-row" key={rule.id}>
<strong>{rule.weekdays.map((day) => dayNames[day].slice(0, 3)).join(', ')}</strong>
<span>{rule.start}-{rule.end}{rule.label ? ` · ${rule.label}` : ''}</span>
<button type="button" onClick={() => deleteAvailabilityRule('recurring', rule.id)}>Remove</button>
</div>
))}
</div>
</section>
<section className="availability-tool">
<div className="card-heading"><h3>One-off blocked time</h3><p>Private exceptions for a single date.</p></div>
<label>Date<input type="date" value={blockForm.date} onChange={(e) => setBlockForm({ ...blockForm, date: e.target.value })} /></label>
<label className="switch-row"><input type="checkbox" checked={blockForm.allDay} onChange={(e) => setBlockForm({ ...blockForm, allDay: e.target.checked })} /><span>All day</span></label>
{!blockForm.allDay && (
<div className="calendar-settings-row">
<label>From<input type="time" value={blockForm.start} onChange={(e) => setBlockForm({ ...blockForm, start: e.target.value })} /></label>
<label>Until<input type="time" value={blockForm.end} onChange={(e) => setBlockForm({ ...blockForm, end: e.target.value })} /></label>
</div>
)}
<label>Private label<input value={blockForm.label} onChange={(e) => setBlockForm({ ...blockForm, label: e.target.value })} placeholder="Dentist" /></label>
<button className="secondary-button" type="button" onClick={addAvailabilityBlock}>Block time</button>
<div className="rule-list">
{availabilityBlocks.length === 0 ? <span>No one-off blocks.</span> : availabilityBlocks.map((block) => (
<div className="rule-row" key={block.id}>
<strong>{block.date}</strong>
<span>{block.allDay ? 'All day' : `${block.start}-${block.end}`}{block.label ? ` · ${block.label}` : ''}</span>
<button type="button" onClick={() => deleteAvailabilityRule('block', block.id)}>Remove</button>
</div>
))}
</div>
</section>
</div>
</section>
<section className="settings-card">
<div className="card-heading"><h2>Page</h2><p>What visitors see first.</p></div>
<label>Title<input value={activeSettings.title} onChange={(e) => setSettings({ ...activeSettings, title: e.target.value })} /></label>
<label>Subtitle<textarea rows={3} value={activeSettings.subtitle} onChange={(e) => setSettings({ ...activeSettings, subtitle: e.target.value })} /></label>
<label>Timezone
<div className="timezone-control">
<select value={activeSettings.timezone} onChange={(e) => setSettings({ ...activeSettings, timezone: e.target.value })}>
{!timezoneOptions.includes(activeSettings.timezone) && <option value={activeSettings.timezone}>{activeSettings.timezone}</option>}
{timezoneOptions.map((zone) => <option key={zone} value={zone}>{zone}</option>)}
</select>
<button
type="button"
onClick={() => setSettings({ ...activeSettings, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || activeSettings.timezone })}
>
Use this browser
</button>
</div>
</label>
</section>
<section className="settings-card">
<div className="card-heading"><h2>Booking rules</h2><p>Enough control, no dashboard archaeology.</p></div>
<div className="two-col-fields">
<label>Minimum notice<input type="number" min="0" value={activeSettings.minimumNoticeMinutes} onChange={(e) => setSettings({ ...activeSettings, minimumNoticeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
<label>Book ahead<input type="number" min="1" max="365" value={activeSettings.bookingWindowDays} onChange={(e) => setSettings({ ...activeSettings, bookingWindowDays: Number(e.target.value) })} /><small>days</small></label>
<label>Buffer before<input type="number" min="0" value={activeSettings.bufferBeforeMinutes} onChange={(e) => setSettings({ ...activeSettings, bufferBeforeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
<label>Buffer after<input type="number" min="0" value={activeSettings.bufferAfterMinutes} onChange={(e) => setSettings({ ...activeSettings, bufferAfterMinutes: Number(e.target.value) })} /><small>minutes</small></label>
</div>
<span className="field-label">Durations</span>
<div className="checkbox-chips">
{[15, 30, 45, 60, 90, 120].map((minutes) => (
<label key={minutes} className={activeSettings.durations.includes(minutes) ? 'active' : ''}>
<input
type="checkbox"
checked={activeSettings.durations.includes(minutes)}
onChange={(e) => setSettings({
...activeSettings,
durations: e.target.checked
? [...activeSettings.durations, minutes].sort((a, b) => a - b)
: activeSettings.durations.filter((value) => value !== minutes),
paymentFreeDurations: e.target.checked
? activeSettings.paymentFreeDurations
: activeSettings.paymentFreeDurations.filter((value) => value !== minutes),
})}
/>
{minutes} min
</label>
))}
</div>
</section>
<section className="settings-card">
<div className="card-heading"><h2>Stripe payments</h2><p>Payment happens before the booking is confirmed or the invitation is sent.</p></div>
<div className="stripe-status-row">
<div className={`connection-badge ${stripeConfigured ? 'connected' : ''}`}>
<span className="status-dot" />
{stripeConfigured ? 'Stripe secrets configured' : 'Stripe secrets missing'}
</div>
{stripeConfigured && (
<span className={`stripe-mode-badge ${stripeMode}`}>
{stripeMode === 'test' ? 'TEST MODE' : stripeMode === 'live' ? 'LIVE MODE' : 'MODE UNKNOWN'}
</span>
)}
</div>
<div className="checkbox-chips">
<label className={activeSettings.paymentEnabled ? 'active' : ''}>
<input
type="checkbox"
checked={activeSettings.paymentEnabled}
onChange={(e) => setSettings({ ...activeSettings, paymentEnabled: e.target.checked })}
/>
Enable Stripe payments
</label>
</div>
<div className="two-col-fields">
<label>Currency<input maxLength={3} value={activeSettings.paymentCurrency} onChange={(e) => setSettings({ ...activeSettings, paymentCurrency: e.target.value.toLowerCase() })} /><small>ISO code</small></label>
<label>Price per minute<input type="number" min="0" step="0.01" value={activeSettings.paymentRatePerMinute} onChange={(e) => setSettings({ ...activeSettings, paymentRatePerMinute: Number(e.target.value) })} /><small>{activeSettings.paymentCurrency.toUpperCase()} / minute</small></label>
</div>
<span className="field-label">Free durations <span className="optional">Optional</span></span>
<div className="checkbox-chips">
{activeSettings.durations.map((minutes) => (
<label key={minutes} className={activeSettings.paymentFreeDurations.includes(minutes) ? 'active' : ''}>
<input
type="checkbox"
checked={activeSettings.paymentFreeDurations.includes(minutes)}
onChange={(e) => setSettings({
...activeSettings,
paymentFreeDurations: e.target.checked
? [...activeSettings.paymentFreeDurations, minutes].sort((a, b) => a - b)
: activeSettings.paymentFreeDurations.filter((value) => value !== minutes),
})}
/>
{minutes} min free
</label>
))}
</div>
<p className="settings-note">Stripe can stay enabled while selected meeting lengths remain free. For example, make 15 minutes free and charge normally for 30, 45, and 60 minutes.</p>
<label>Checkout label<input value={activeSettings.paymentLabel} onChange={(e) => setSettings({ ...activeSettings, paymentLabel: e.target.value })} placeholder="Booking payment" /></label>
<p className="theme-footnote">
Enter normal currency, not Stripe minor units. Example: <strong>50</strong> with SEK means <strong>SEK 50 per minute</strong>, so a 15-minute booking costs <strong>{formatMoneyMajor(50 * 15, 'sek')}</strong>.
</p>
{activeSettings.paymentEnabled && !stripeConfigured && <div className="error-banner" role="alert">Stripe secrets are missing. Durations marked free can still be booked, but paid durations will be unavailable.</div>}
{stripeConfigured && stripeMode === 'unknown' && <div className="error-banner" role="alert">The Stripe secret key is configured, but its mode could not be identified. Slot expects a key beginning with sk_test_ or sk_live_.</div>}
{stripeConfigured && stripeMode === 'test' && <p className="settings-note">Stripe test mode only emails test receipts to verified Stripe account team-member addresses. Every successful payment still gets a hosted Stripe receipt link on Slot's confirmation screen.</p>}
</section>
<section className="settings-card wide-card">
<div className="card-heading"><h2>Google Calendar</h2><p>Busy time is read from Google. New bookings are created on your chosen calendar.</p></div>
<div className="google-status-row">
<div className={`connection-badge ${session.connected ? 'connected' : ''}`}>
<span className="status-dot" />
{session.connected ? 'Google connected' : 'Google disconnected'}
</div>
<div className="connection-actions">
<a href={session.connected ? '/auth/google/start?force=1' : '/auth/google/start'}>{session.connected ? 'Reconnect' : 'Connect Google'}</a>
{session.connected && (
<button type="button" onClick={async () => {
await api('/api/admin/disconnect-google', { method: 'POST' });
setSession({ ...session, connected: false });
}}>Disconnect</button>
)}
</div>
</div>
<div className="calendar-settings-row">
<label>Create events in<input value={activeSettings.calendarId} onChange={(e) => setSettings({ ...activeSettings, calendarId: e.target.value })} placeholder="primary" /></label>
<label>Calendars that block time<input value={activeSettings.busyCalendarIds.join(', ')} onChange={(e) => setSettings({ ...activeSettings, busyCalendarIds: e.target.value.split(',').map((v) => v.trim()).filter(Boolean) })} placeholder="primary" /></label>
</div>
<label>Default meeting type
<select value={activeSettings.defaultMeetingMode} onChange={(e) => setSettings({ ...activeSettings, defaultMeetingMode: e.target.value as MeetingMode })}>
{activeSettings.allowMeetingModes.map((mode) => <option value={mode} key={mode}>{modeMeta[mode].label}</option>)}
</select>
</label>
<span className="field-label">Allowed meeting types</span>
<div className="mode-admin-grid">
{(Object.keys(modeMeta) as MeetingMode[]).map((mode) => {
const Icon = modeMeta[mode].icon;
const active = activeSettings.allowMeetingModes.includes(mode);
return (
<label key={mode} className={active ? 'active' : ''}>
<input type="checkbox" checked={active} disabled={active && activeSettings.allowMeetingModes.length === 1} onChange={(e) => setSettings({
...activeSettings,
allowMeetingModes: e.target.checked
? [...activeSettings.allowMeetingModes, mode]
: activeSettings.allowMeetingModes.filter((value) => value !== mode),
defaultMeetingMode: !e.target.checked && activeSettings.defaultMeetingMode === mode
? (activeSettings.allowMeetingModes.find((value) => value !== mode) ?? activeSettings.defaultMeetingMode)
: activeSettings.defaultMeetingMode,
})} />
<Icon size={18} /><span>{modeMeta[mode].label}</span>
</label>
);
})}
</div>
{activeSettings.allowMeetingModes.includes('in_person') && (
<label>In-person location<input value={activeSettings.inPersonLocation} onChange={(e) => setSettings({ ...activeSettings, inPersonLocation: e.target.value })} placeholder="Office, café, address…" /></label>
)}
</section>
</div>
</>
) : tab === 'activity' ? (
<>
<div className="admin-heading">
<div><p className="eyebrow">ACTIVITY</p><h1>Your Slot at a glance.</h1></div>
<button className="secondary-button activity-refresh" type="button" onClick={loadActivity} disabled={activityLoading}>
{activityLoading ? <><Loader2 className="spin" size={16} /> Refreshing…</> : <><Activity size={16} /> Refresh</>}
</button>
</div>
{error && <div className="error-banner" role="alert">{error}</div>}
{activityLoading && !activity ? (
<section className="activity-loading"><Loader2 className="spin" size={20} /> Loading activity…</section>
) : activity ? (
<>
<section className="activity-metrics" aria-label="Booking activity summary">
<article className="activity-metric"><span>Bookings</span><strong>{activity.stats.total_bookings}</strong><small>All confirmed bookings, including later cancellations</small></article>
<article className="activity-metric"><span>Upcoming</span><strong>{activity.stats.upcoming}</strong><small>Confirmed meetings still ahead</small></article>
<article className="activity-metric"><span>Completed</span><strong>{activity.stats.completed}</strong><small>Confirmed meetings whose end time has passed</small></article>
<article className="activity-metric"><span>Cancelled</span><strong>{activity.stats.cancelled}</strong><small>Cancelled meetings, excluding abandoned payment holds</small></article>
<article className="activity-metric"><span>Reschedules</span><strong>{activity.stats.reschedules}</strong><small>Total times confirmed meetings were moved</small></article>
<article className="activity-metric"><span>Paid bookings</span><strong>{activity.stats.paid_bookings}</strong><small>Bookings with a completed Stripe payment</small></article>
<article className="activity-metric"><span>Average length</span><strong>{activity.stats.average_minutes} min</strong><small>Average duration of current confirmed meetings</small></article>
{activity.paymentsEnabled && (
<>
<article className="activity-metric money"><span>Revenue</span><strong>{activityMoneyText(activity.money, 'revenue', activeSettings.paymentCurrency)}</strong><small>Gross successful Stripe payments</small></article>
<article className="activity-metric money"><span>Refunds</span><strong>{activityMoneyText(activity.money, 'refunds', activeSettings.paymentCurrency)}</strong><small>Successful or currently pending refunds</small></article>
<article className="activity-metric money"><span>Net revenue</span><strong>{activityMoneyText(activity.money, 'net', activeSettings.paymentCurrency)}</strong><small>Gross revenue minus refunds</small></article>
<article className="activity-metric"><span>Open payment holds</span><strong>{activity.stats.open_payment_holds}</strong><small>Visitors currently inside the payment window</small></article>
</>
)}
</section>
<section className="activity-feed-card">
<div className="activity-feed-heading">
<div><p className="eyebrow">RECENT ACTIVITY</p><h2>What happened.</h2></div>
<span>Latest {Math.min(activity.events.length, 100)} events</span>
</div>
{activity.events.length === 0 ? (
<div className="empty-list activity-empty"><Activity size={25} /><strong>No activity yet</strong><span>Bookings, payments, reschedules, cancellations, and refunds will appear here.</span></div>
) : (
<div className="activity-feed">
{activity.events.map((event) => {
const copy = activityEventCopy(event, activeSettings.timezone);
return (
<article className={`activity-feed-row type-${event.type}`} key={event.id}>
<span className="activity-feed-mark" aria-hidden="true" />
<div className="activity-feed-copy">
<strong>{copy.title}</strong>
<span>{copy.detail}</span>
</div>
<time dateTime={databaseInstant(event.occurred_at)}>{formatInstant(databaseInstant(event.occurred_at), activeSettings.timezone)}</time>
</article>
);
})}
</div>
)}
</section>
</>
) : (
<section className="activity-loading">Activity could not be loaded.</section>
)}
</>
) : tab === 'attendees' ? (
<>
<div className="admin-heading">
<div><p className="eyebrow">ATTENDEES</p><h1>Who keeps booking.</h1></div>
<button className="secondary-button activity-refresh" type="button" onClick={loadAttendees} disabled={attendeesLoading}>
{attendeesLoading ? <><Loader2 className="spin" size={16} /> Refreshing…</> : <><Users size={16} /> Refresh</>}
</button>
</div>
{error && <div className="error-banner" role="alert">{error}</div>}
<label className="admin-search attendee-search">
<Search size={16} aria-hidden="true" />
<input value={attendeeSearch} onChange={(event) => setAttendeeSearch(event.target.value)} placeholder="Search attendee name or email" aria-label="Search attendees" />
</label>
{attendeesLoading && !attendeeData ? (
<section className="activity-loading"><Loader2 className="spin" size={20} /> Loading attendees…</section>
) : attendeeData ? (
<>
<section className="attendee-list-card">
<div className="attendee-list-head">
<span>Attendee</span><span>Bookings</span><span>Completed</span><span>Cancelled</span><span>Reschedules</span><span>Last booked</span><span />
</div>
{shownAttendees.length === 0 ? (
<div className="empty-list activity-empty"><Users size={25} /><strong>No attendees found</strong><span>{attendeeSearch ? 'Try another name or email.' : 'Confirmed attendees will appear here after their first booking.'}</span></div>
) : shownAttendees.map((attendee) => (
<article className="attendee-list-row" key={attendee.email.toLowerCase()}>
<div className="attendee-person"><span className="attendee-avatar" aria-hidden="true">{attendee.name.slice(0, 1).toUpperCase()}</span><div><strong>{attendee.name}</strong><span>{attendee.email}</span></div></div>
<strong>{attendee.total_bookings}</strong>
<span>{attendee.completed}</span>
<span>{attendee.cancelled}</span>
<span>{attendee.reschedules}</span>
<time>{attendee.last_booked_at ? formatInstant(databaseInstant(attendee.last_booked_at), activeSettings.timezone) : '—'}</time>
<button type="button" className="icon-button" aria-label={`Open ${attendee.name} attendee history`} onClick={() => void openAttendee(attendee)}><Inbox size={15} /></button>
</article>
))}
</section>
{selectedAttendee && (
<section className="booking-detail-card attendee-detail-card">
<div className="card-heading">
<div><h2>{selectedAttendee.name}</h2><p>{selectedAttendee.email}</p></div>
<button type="button" className="text-button" onClick={() => setSelectedAttendee(null)}>Close</button>
</div>
<div className="attendee-metrics">
<article><span>Bookings</span><strong>{selectedAttendee.total_bookings}</strong></article>
<article><span>Upcoming</span><strong>{selectedAttendee.upcoming}</strong></article>
<article><span>Completed</span><strong>{selectedAttendee.completed}</strong></article>
<article><span>Cancelled</span><strong>{selectedAttendee.cancelled}</strong></article>
<article><span>Reschedules</span><strong>{selectedAttendee.reschedules}</strong></article>
<article><span>Booked time</span><strong>{selectedAttendee.total_minutes} min</strong></article>
<article><span>Average length</span><strong>{selectedAttendee.average_minutes} min</strong></article>
{attendeeData.paymentsEnabled && <article><span>Paid bookings</span><strong>{selectedAttendee.paid_bookings}</strong></article>}
{attendeeData.paymentsEnabled && <article><span>Refunded</span><strong>{selectedAttendee.refunded_bookings}</strong></article>}
</div>
<div className="attendee-history-heading"><div><p className="eyebrow">BOOKING HISTORY</p><h3>Recent meetings</h3></div><span>{attendeeBookings.length} shown</span></div>
{attendeeBookingsLoading ? (
<div className="activity-loading attendee-history-loading"><Loader2 className="spin" size={18} /> Loading booking history…</div>
) : attendeeBookings.length ? (
<div className="attendee-booking-history">{attendeeBookings.map((booking) => <BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} onOpen={openBookingFromAttendee} />)}</div>
) : (
<div className="empty-list attendee-history-empty"><Inbox size={22} /><strong>No booking history</strong></div>
)}
</section>
)}
</>
) : <section className="activity-loading">Attendees could not be loaded.</section>}
</>
) : (
<>
<div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Your meetings.</h1></div></div>
{notice && <div className="success-banner" role="status">{notice}</div>}
{error && <div className="error-banner" role="alert">{error}</div>}
<div className="booking-tabs" role="tablist" aria-label="Filter bookings">
<button
role="tab"
aria-selected={bookingFilter === 'upcoming'}
className={`booking-tab ${bookingFilter === 'upcoming' ? 'active' : ''}`}
onClick={() => setBookingFilter('upcoming')}
>
Upcoming <span className="count">{upcoming.length}</span>
</button>
<button
role="tab"
aria-selected={bookingFilter === 'past'}
className={`booking-tab ${bookingFilter === 'past' ? 'active' : ''}`}
onClick={() => setBookingFilter('past')}
>
Past <span className="count">{past.length}</span>
</button>
</div>
<label className="admin-search booking-search">
<Search size={16} aria-hidden="true" />
<input
value={bookingSearch}
onChange={(event) => setBookingSearch(event.target.value)}
placeholder="Search booking ID, name or email"
aria-label="Search bookings by booking ID, name or email"
autoComplete="off"
/>
{bookingSearchLoading && <Loader2 className="spin admin-search-spinner" size={15} aria-label="Searching" />}
</label>
<section className="booking-list-card">
{bookingSearchLoading && bookingSearch.trim() && bookingSearchResults === null ? (
<div className="empty-list"><Loader2 className="spin" size={23} /><strong>Searching bookings…</strong></div>
) : shown.length === 0 ? (
<div className="empty-list">
<Inbox size={26} />
<strong>{bookingSearch.trim() ? 'No matching bookings' : bookingFilter === 'upcoming' ? 'No upcoming bookings' : 'No past bookings'}</strong>
<span>{bookingSearch.trim() ? `No ${bookingFilter} booking matches “${bookingSearch.trim()}”.` : bookingFilter === 'upcoming' ? 'No Slot bookings are upcoming. Google Calendar busy time and availability rules can still make public times unavailable.' : 'Completed meetings will be listed here.'}</span>
</div>
) : (
shown.map((booking) => (
<BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} onOpen={setSelectedBooking} />
))
)}
</section>
{selectedBooking && (
<section className="booking-detail-card">
<div className="card-heading">
<div>
<h2>{selectedBooking.name}</h2>
<p>{selectedBooking.email}</p>
</div>
<button type="button" className="text-button" onClick={() => setSelectedBooking(null)}>Close</button>
</div>
<dl className="booking-detail-grid">
<div><dt>Status</dt><dd>{selectedBooking.status}</dd></div>
{selectedBooking.public_booking_id && <div><dt>Booking ID</dt><dd>{selectedBooking.public_booking_id}</dd></div>}
<div><dt>Date</dt><dd>{formatDateShort(selectedBooking.start_time, activeSettings.timezone)}</dd></div>
<div><dt>Start</dt><dd>{formatClock(selectedBooking.start_time, activeSettings.timezone)}</dd></div>
<div><dt>End</dt><dd>{formatClock(selectedBooking.end_time, activeSettings.timezone)}</dd></div>
<div><dt>Duration</dt><dd>{selectedBooking.duration_minutes} min</dd></div>
<div><dt>Timezone</dt><dd>{activeSettings.timezone}</dd></div>
<div><dt>Meeting</dt><dd>{modeMeta[selectedBooking.meeting_mode].label}</dd></div>
<div><dt>Calendar</dt><dd>{selectedBooking.calendar_provider ?? 'google'}</dd></div>
<div><dt>Sync</dt><dd>{selectedBooking.calendar_sync_status ?? 'synced'}</dd></div>
<div><dt>Payment</dt><dd>{selectedBooking.payment_status ?? 'not_requested'}</dd></div>
{selectedBooking.payment_status === 'paid' && <div><dt>Refund</dt><dd>{selectedBooking.refund_status ?? 'not_requested'}</dd></div>}
{selectedBooking.payment_amount != null && selectedBooking.payment_currency && (
<div><dt>Amount</dt><dd>{formatMoneyMinor(selectedBooking.payment_amount, selectedBooking.payment_currency)}</dd></div>
)}
{selectedBooking.paid_at && <div><dt>Paid</dt><dd>{formatInstant(`${selectedBooking.paid_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
{selectedBooking.created_at && <div><dt>Created</dt><dd>{formatInstant(`${selectedBooking.created_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
{selectedBooking.rescheduled_at && <div><dt>Rescheduled</dt><dd>{formatInstant(`${selectedBooking.rescheduled_at.replace(' ', 'T')}Z`, activeSettings.timezone)}{selectedBooking.reschedule_count ? ` · ${selectedBooking.reschedule_count}×` : ''}</dd></div>}
{selectedBooking.proposal_status === 'pending' && selectedBooking.proposed_start_time && <div><dt>Suggested time</dt><dd>{formatDateShort(selectedBooking.proposed_start_time, activeSettings.timezone)} {formatClock(selectedBooking.proposed_start_time, activeSettings.timezone)}</dd></div>}
{selectedBooking.refunded_at && <div><dt>Refunded</dt><dd>{formatInstant(`${selectedBooking.refunded_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
{selectedBooking.cancelled_at && <div><dt>Cancelled</dt><dd>{formatInstant(`${selectedBooking.cancelled_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
</dl>
{selectedBooking.calendar_sync_status === 'failed' && selectedBooking.calendar_sync_error && (
<div className="error-banner" role="alert">{selectedBooking.calendar_sync_error}</div>
)}
<div className="booking-detail-actions">
{selectedBooking.status === 'confirmed' && selectedBooking.meet_url && <a className="secondary-button" href={selectedBooking.meet_url} target="_blank" rel="noreferrer">Open meeting link</a>}
<button
className="secondary-button"
type="button"
disabled={selectedBooking.status !== 'confirmed' || Date.parse(selectedBooking.end_time) < Date.now()}
onClick={() => openScheduleDialog(selectedBooking, 'reschedule')}
>Reschedule</button>
<button
className="secondary-button"
type="button"
disabled={selectedBooking.status !== 'confirmed' || Date.parse(selectedBooking.end_time) < Date.now()}
onClick={() => openScheduleDialog(selectedBooking, 'suggest')}
>Suggest new time</button>
<button
className="danger-button"
type="button"
disabled={!(selectedBooking.status === 'pending' || selectedBooking.status === 'confirmed') || Date.parse(selectedBooking.end_time) < Date.now()}
onClick={() => setCancelTarget(selectedBooking)}
>
{selectedBooking.status === 'pending' ? 'Release payment hold' : 'Cancel meeting'}
</button>
</div>
</section>
)}
</>
)}
</section>
</div>
{scheduleTarget && (
<div className="modal-backdrop" role="presentation">
<section className="confirm-dialog schedule-dialog" role="dialog" aria-modal="true" aria-labelledby="schedule-action-title">
<p className="eyebrow">{scheduleAction === 'reschedule' ? 'RESCHEDULE' : 'SUGGEST NEW TIME'}</p>
<h2 id="schedule-action-title">{scheduleAction === 'reschedule' ? 'Move this meeting?' : 'Propose a different time?'}</h2>
<div className="cancel-summary">
<strong>{scheduleTarget.name}</strong>
<span>Current: {formatDateShort(scheduleTarget.start_time, activeSettings.timezone)}</span>
<span>{formatClock(scheduleTarget.start_time, activeSettings.timezone)}-{formatClock(scheduleTarget.end_time, activeSettings.timezone)} · {scheduleTarget.duration_minutes} min</span>
</div>
<p className="muted">{scheduleAction === 'reschedule'
? 'Choose an available start time. The existing Google Calendar event is moved and the attendee receives an updated invitation.'
: 'The existing meeting stays exactly where it is. Slot sends a Google Calendar update containing a secure acceptance link for the proposed time.'}</p>
{!suggestionUrl ? (
<>
<label className="schedule-date-label">Date
<input
type="date"
min={todayInZone(activeSettings.timezone)}
value={scheduleDate}
onChange={(event) => setScheduleDate(event.target.value)}
/>
</label>
<div className="schedule-slot-picker" aria-label="Available start times">
{loadingScheduleSlots ? (
<span className="schedule-loading"><Loader2 className="spin" size={17} /> Checking availability…</span>
) : scheduleSlots.length ? (
scheduleSlots.map((slot) => (
<button
type="button"
key={slot.start}
className={scheduleStart === slot.start ? 'active' : ''}
onClick={() => setScheduleStart(slot.start)}
>
{slot.time}
</button>
))
) : (
<span className="schedule-loading">No {scheduleTarget.duration_minutes}-minute starts are available on this date.</span>
)}
</div>
</>
) : (
<div className="suggestion-link-box">
<strong>Suggestion sent</strong>
<span>Google Calendar notified the attendee. You can also send this link directly:</span>
<div className="suggestion-link-row">
<input readOnly value={suggestionUrl} aria-label="Reschedule suggestion link" />
<button type="button" className="secondary-button" onClick={() => copyScheduleLink(suggestionUrl)}>
{scheduleLinkCopied ? <><Check size={15} /> Copied</> : <><Copy size={15} /> Copy</>}
</button>
</div>
</div>
)}
<div className="dialog-actions">
<button
className="secondary-button"
type="button"
onClick={() => { setScheduleTarget(null); setSuggestionUrl(''); }}
disabled={savingScheduleAction}
>{suggestionUrl ? 'Done' : 'Close'}</button>
{!suggestionUrl && (
<button className="primary-button" type="button" onClick={submitScheduleAction} disabled={!scheduleStart || savingScheduleAction}>
{savingScheduleAction
? <><Loader2 className="spin" size={16} /> {scheduleAction === 'reschedule' ? 'Rescheduling…' : 'Sending…'}</>
: scheduleAction === 'reschedule' ? 'Reschedule meeting' : 'Send suggestion'}
</button>
)}
</div>
</section>
</div>
)}
{cancelTarget && (
<div className="modal-backdrop" role="presentation">
<section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="cancel-title">
<p className="eyebrow">{cancelTarget.status === 'pending' ? 'PAYMENT HOLD' : 'CANCEL MEETING'}</p>
<h2 id="cancel-title">{cancelTarget.status === 'pending' ? 'Release this payment hold?' : 'Cancel meeting?'}</h2>
<div className="cancel-summary">
<strong>{cancelTarget.name}</strong>
<span>{formatDateShort(cancelTarget.start_time, activeSettings.timezone)}</span>
<span>{formatClock(cancelTarget.start_time, activeSettings.timezone)}-{formatClock(cancelTarget.end_time, activeSettings.timezone)}</span>
<span>{cancelTarget.duration_minutes} min</span>
</div>
<p className="muted">{cancelTarget.status === 'pending'
? 'This expires the open Stripe Checkout, removes the temporary calendar hold, and makes the time bookable again.'
: 'This will also cancel the Google Calendar event and send normal Calendar cancellation notifications.'}</p>
{cancelTarget.status === 'pending'
&& checkoutSessionMode(cancelTarget.stripe_checkout_session_id) !== 'unknown'
&& stripeMode !== 'unknown'
&& checkoutSessionMode(cancelTarget.stripe_checkout_session_id) !== stripeMode && (
<div className="error-banner" role="alert">
This hold was created in Stripe {checkoutSessionMode(cancelTarget.stripe_checkout_session_id).toUpperCase()} mode, but Slot is currently using {stripeMode.toUpperCase()} mode. The current key cannot expire that old Checkout session. Releasing will still remove Slot's lock and calendar hold; the old Stripe Checkout may remain payable until its own expiry.
</div>
)}
{cancelTarget.payment_status === 'paid' && (
<div className="refund-banner" role="status">
<strong>Full Stripe refund included</strong>
<span>{cancelTarget.payment_amount != null && cancelTarget.payment_currency
? `${formatMoneyMinor(cancelTarget.payment_amount, cancelTarget.payment_currency)} will be refunded to the original payment method before Slot finalizes the cancellation.`
: 'Slot will refund the Stripe payment to the original payment method before finalizing the cancellation.'}</span>
</div>
)}
<div className="dialog-actions">
<button className="secondary-button" type="button" onClick={() => setCancelTarget(null)} disabled={cancelling}>{cancelTarget.status === 'pending' ? 'Keep hold' : 'Keep meeting'}</button>
<button className="danger-button" type="button" onClick={confirmCancelBooking} disabled={cancelling}>
{cancelling
? <><Loader2 className="spin" size={16} /> {cancelTarget.status === 'pending' ? 'Releasing...' : 'Cancelling...'}</>
: cancelTarget.status === 'pending' ? 'Release hold' : cancelTarget.payment_status === 'paid' ? 'Cancel & refund' : 'Cancel meeting'}
</button>
</div>
</section>
</div>
)}
</main>
);
}
export default function App() {
if (window.location.pathname.startsWith('/admin')) return <Admin />;
if (window.location.pathname.startsWith('/reschedule/')) return <RescheduleSuggestionPage />;
return <PublicBooking />;
}