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
Trace
src/App.tsx
Trace helps you understand code history line by line. See who changed each line, when it changed, and which commit introduced it.
Author
Date
Commit
Line
Code
1
import {
2
Activity,
3
ArrowLeft,
4
ArrowRight,
5
CalendarDays,
6
CalendarPlus,
7
Check,
8
ChevronLeft,
9
ChevronRight,
10
Clock3,
11
Copy,
12
CreditCard,
13
ExternalLink,
14
Inbox,
15
Loader2,
16
LogOut,
17
MapPin,
18
MonitorUp,
19
Phone,
20
Search,
21
Settings2,
22
Sparkles,
23
Users,
24
Video,
25
} from 'lucide-react';
26
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
28
type MeetingMode = 'google_meet' | 'phone' | 'in_person' | 'decide_later';
30
type PublicConfig = {
31
title: string;
32
subtitle: string;
33
timezone: string;
34
bookingWindowDays: number;
35
durations: number[];
36
defaultMeetingMode: MeetingMode;
37
allowMeetingModes: MeetingMode[];
38
availableWeekdays: number[];
39
connected: boolean;
40
payment?: PaymentOption;
41
};
43
type PaymentOption = {
44
enabled: true;
45
required: boolean;
46
ready: boolean;
47
ratePerMinute: number;
48
freeDurations: number[];
49
currency: string;
50
label: string;
51
};
53
type DurationBlockReason = 'outside_hours' | 'unavailable';
54
type Slot = {
55
start: string;
56
time: string;
57
durations: number[];
58
blockedDurations?: Record<string, DurationBlockReason>;
59
};
61
type Confirmation = {
62
id: string;
63
bookingId?: string | null;
64
start: string;
65
end: string;
66
duration: number;
67
timezone: string;
68
meetingMode: MeetingMode;
69
meetUrl?: string | null;
70
receiptUrl?: string | null;
71
};
73
type AppSettings = {
74
title: string;
75
subtitle: string;
76
timezone: string;
77
bookingWindowDays: number;
78
minimumNoticeMinutes: number;
79
bufferBeforeMinutes: number;
80
bufferAfterMinutes: number;
81
slotIncrementMinutes: number;
82
durations: number[];
83
calendarId: string;
84
busyCalendarIds: string[];
85
defaultMeetingMode: MeetingMode;
86
allowMeetingModes: MeetingMode[];
87
inPersonLocation: string;
88
paymentEnabled: boolean;
89
paymentRatePerMinute: number;
90
paymentFreeDurations: number[];
91
paymentCurrency: string;
92
paymentLabel: string;
93
availability: Record<string, Array<{ start: string; end: string }>>;
94
};
96
type BookingRow = {
97
id: string;
98
public_booking_id?: string | null;
99
name: string;
100
email: string;
101
start_time: string;
102
end_time: string;
103
duration_minutes: number;
104
meeting_mode: MeetingMode;
105
status: string;
106
meet_url?: string;
107
calendar_provider?: string;
108
calendar_sync_status?: string;
109
calendar_sync_error?: string;
110
calendar_sync_updated_at?: string;
111
payment_status?: string;
112
payment_amount?: number;
113
payment_currency?: string;
114
stripe_checkout_session_id?: string;
115
stripe_payment_intent_id?: string;
116
paid_at?: string;
117
cancelled_at?: string;
118
created_at?: string;
119
rescheduled_at?: string;
120
reschedule_count?: number;
121
proposed_start_time?: string;
122
proposed_end_time?: string;
123
proposed_at?: string;
124
proposal_status?: string;
125
refund_status?: string;
126
stripe_refund_id?: string;
127
refunded_amount?: number;
128
refunded_at?: string;
129
};
131
type ActivityStats = {
132
total_bookings: number;
133
upcoming: number;
134
completed: number;
135
cancelled: number;
136
reschedules: number;
137
paid_bookings: number;
138
average_minutes: number;
139
open_payment_holds: number;
140
};
142
type ActivityMoney = {
143
currency: string;
144
revenue: number;
145
refunds: number;
146
net: number;
147
};
149
type ActivityEvent = {
150
id: string;
151
booking_id?: string | null;
152
type: string;
153
occurred_at: string;
154
metadata: Record<string, unknown>;
155
name?: string | null;
156
email?: string | null;
157
start_time?: string | null;
158
end_time?: string | null;
159
duration_minutes?: number | null;
160
payment_amount?: number | null;
161
payment_currency?: string | null;
162
};
164
type ActivityData = {
165
paymentsEnabled: boolean;
166
stats: ActivityStats;
167
money: ActivityMoney[];
168
events: ActivityEvent[];
169
};
171
type AttendeeRow = {
172
email: string;
173
name: string;
174
total_bookings: number;
175
upcoming: number;
176
completed: number;
177
cancelled: number;
178
reschedules: number;
179
paid_bookings: number;
180
refunded_bookings: number;
181
total_minutes: number;
182
average_minutes: number;
183
first_booked_at?: string | null;
184
last_booked_at?: string | null;
185
};
187
type AttendeeData = {
188
paymentsEnabled: boolean;
189
attendees: AttendeeRow[];
190
};
192
type RescheduleProposal = {
193
title: string;
194
name: string;
195
currentStart: string;
196
currentEnd: string;
197
proposedStart: string;
198
proposedEnd: string;
199
duration: number;
200
timezone: string;
201
meetingMode: MeetingMode;
202
};
204
type ManagedBooking = {
205
bookingId: string;
206
name: string;
207
start: string;
208
end: string;
209
duration: number;
210
timezone: string;
211
meetingMode: MeetingMode;
212
status: string;
213
paymentStatus: string;
214
refundStatus: string;
215
canCancel: boolean;
216
canReschedule: boolean;
217
};
219
type ManageAction = 'none' | 'cancel' | 'reschedule';
221
type BookingScheduleAction = 'reschedule' | 'suggest';
223
type RecurringUnavailableRule = {
224
id: string;
225
weekdays: number[];
226
start: string;
227
end: string;
228
label?: string | null;
229
};
231
type AvailabilityBlock = {
232
id: string;
233
date: string;
234
start?: string | null;
235
end?: string | null;
236
allDay: boolean;
237
label?: string | null;
238
};
240
type AdminSession = { authenticated: boolean; email: string; connected: boolean };
241
type ThemeId = 'light' | 'paper' | 'beige' | 'dark' | 'midnight' | 'rosewood' | 'forest' | 'true-black';
243
const modeMeta: Record<MeetingMode, { label: string; icon: typeof Video; help: string }> = {
244
google_meet: { label: 'Google Meet', icon: Video, help: 'A Meet link is created automatically.' },
245
phone: { label: 'Phone', icon: Phone, help: 'Add a number for the call.' },
246
in_person: { label: 'In person', icon: MapPin, help: 'The location will be included in the invite.' },
247
decide_later: { label: 'Decide later', icon: Sparkles, help: 'Book the time first and sort out the place later.' },
248
};
250
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
251
const shortDayNames = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
252
const timezoneOptions = [
253
'Europe/Stockholm',
254
'Europe/London',
255
'Europe/Berlin',
256
'Europe/Paris',
257
'Europe/Madrid',
258
'Europe/Amsterdam',
259
'Europe/Helsinki',
260
'UTC',
261
'America/New_York',
262
'America/Chicago',
263
'America/Denver',
264
'America/Los_Angeles',
265
'America/Toronto',
266
'Asia/Tokyo',
267
'Asia/Singapore',
268
'Australia/Sydney',
269
];
270
const themes: Array<{ id: ThemeId; name: string; description: string; contrast: string }> = [
271
{ id: 'light', name: 'Light', description: 'Bright surfaces with dark text and clear contrast.', contrast: 'Light contrast' },
272
{ id: 'paper', name: 'Paper', description: 'Cool, document-like surfaces with crisp separation.', contrast: 'Crisp contrast' },
273
{ id: 'beige', name: 'Beige', description: 'Warm neutral surfaces for a softer reading experience.', contrast: 'Warm contrast' },
274
{ id: 'dark', name: 'Dark', description: 'Dark grey surfaces that reduce glare while keeping depth.', contrast: 'Soft contrast' },
275
{ id: 'midnight', name: 'Midnight', description: 'Deep navy surfaces for calm late-night work.', contrast: 'Cool contrast' },
276
{ id: 'rosewood', name: 'Rosewood', description: 'Warm dark surfaces with copper and walnut tones.', contrast: 'Warm dark' },
277
{ id: 'forest', name: 'Forest', description: 'Muted pine surfaces with restrained natural contrast.', contrast: 'Natural dark' },
278
{ id: 'true-black', name: 'True black', description: 'Pure black backgrounds with maximum dark-mode contrast.', contrast: 'High contrast' },
279
];
281
async function api<T>(url: string, init?: RequestInit): Promise<T> {
282
const response = await fetch(url, {
283
...init,
284
headers: {
285
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
286
...(init?.headers ?? {}),
287
},
288
});
289
const data = (await response.json().catch(() => ({}))) as T & { error?: string };
290
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
291
return data;
292
}
294
function checkoutSessionMode(sessionId?: string): 'test' | 'live' | 'unknown' {
295
if (sessionId?.startsWith('cs_test_')) return 'test';
296
if (sessionId?.startsWith('cs_live_')) return 'live';
297
return 'unknown';
298
}
300
function dateKey(date: Date): string {
301
const year = date.getFullYear();
302
const month = String(date.getMonth() + 1).padStart(2, '0');
303
const day = String(date.getDate()).padStart(2, '0');
304
return `${year}-${month}-${day}`;
305
}
307
function parseDateKey(value: string): Date {
308
const [y, m, d] = value.split('-').map(Number);
309
return new Date(y, m - 1, d, 12, 0, 0);
310
}
312
function addDays(date: Date, amount: number): Date {
313
const copy = new Date(date);
314
copy.setDate(copy.getDate() + amount);
315
return copy;
316
}
318
function todayInZone(timezone: string): string {
319
const parts = new Intl.DateTimeFormat('en-CA', {
320
timeZone: timezone,
321
year: 'numeric',
322
month: '2-digit',
323
day: '2-digit',
324
}).formatToParts(new Date());
325
const map = Object.fromEntries(parts.filter((p) => p.type !== 'literal').map((p) => [p.type, p.value]));
326
return `${map.year}-${map.month}-${map.day}`;
327
}
329
function dateKeyForInstant(iso: string, timezone: string): string {
330
const parts = new Intl.DateTimeFormat('en-CA', {
331
timeZone: timezone,
332
year: 'numeric',
333
month: '2-digit',
334
day: '2-digit',
335
}).formatToParts(new Date(iso));
336
const map = Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
337
return `${map.year}-${map.month}-${map.day}`;
338
}
340
function prettyDate(key: string, timezone: string): string {
341
return new Intl.DateTimeFormat('en-US', {
342
timeZone: timezone,
343
weekday: 'long',
344
month: 'long',
345
day: 'numeric',
346
}).format(new Date(`${key}T12:00:00Z`));
347
}
349
function dayAriaLabel(date: Date): string {
350
return new Intl.DateTimeFormat('en-US', {
351
weekday: 'long',
352
month: 'long',
353
day: 'numeric',
354
year: 'numeric',
355
}).format(date);
356
}
358
function formatInstant(iso: string, timezone: string): string {
359
return new Intl.DateTimeFormat('en-US', {
360
timeZone: timezone,
361
weekday: 'short',
362
month: 'short',
363
day: 'numeric',
364
hour: '2-digit',
365
minute: '2-digit',
366
hourCycle: 'h23',
367
}).format(new Date(iso));
368
}
370
function formatDateShort(iso: string, timezone: string): string {
371
return new Intl.DateTimeFormat('en-US', {
372
timeZone: timezone,
373
weekday: 'short',
374
month: 'short',
375
day: 'numeric',
376
}).format(new Date(iso));
377
}
379
function formatClock(iso: string, timezone: string): string {
380
return new Intl.DateTimeFormat('en-US', {
381
timeZone: timezone,
382
hour: '2-digit',
383
minute: '2-digit',
384
hourCycle: 'h23',
385
}).format(new Date(iso));
386
}
388
function formatMoneyMinor(amountMinor: number, currency: string): string {
389
try {
390
const formatter = new Intl.NumberFormat('en-US', {
391
style: 'currency',
392
currency: currency.toUpperCase(),
393
});
394
const digits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
395
return formatter.format(amountMinor / (10 ** digits));
396
} catch {
397
return `${amountMinor} ${currency.toUpperCase()}`;
398
}
399
}
401
function formatMoneyMajor(amount: number, currency: string): string {
402
try {
403
return new Intl.NumberFormat('en-US', {
404
style: 'currency',
405
currency: currency.toUpperCase(),
406
maximumFractionDigits: 2,
407
}).format(amount);
408
} catch {
409
return `${amount.toFixed(2)} ${currency.toUpperCase()}`;
410
}
411
}
413
function databaseInstant(value: string): string {
414
if (/Z$|[+-]\d{2}:?\d{2}$/.test(value)) return value;
415
return `${value.replace(' ', 'T')}Z`;
416
}
418
function activityMoneyText(money: ActivityMoney[], field: 'revenue' | 'refunds' | 'net', preferredCurrency: string): string {
419
if (!money.length) return formatMoneyMinor(0, preferredCurrency);
420
const sorted = [...money].sort((a, b) => {
421
if (a.currency === preferredCurrency.toLowerCase()) return -1;
422
if (b.currency === preferredCurrency.toLowerCase()) return 1;
423
return a.currency.localeCompare(b.currency);
424
});
425
return sorted.map((row) => formatMoneyMinor(row[field], row.currency)).join(' · ');
426
}
428
function activityEventCopy(event: ActivityEvent, timezone: string): { title: string; detail: string } {
429
const name = event.name || 'A visitor';
430
const meta = event.metadata ?? {};
431
const amount = typeof meta.amount === 'number' ? meta.amount : event.payment_amount;
432
const currency = typeof meta.currency === 'string' ? meta.currency : event.payment_currency;
433
const money = amount != null && currency ? formatMoneyMinor(amount, currency) : null;
434
const duration = typeof meta.duration === 'number' ? meta.duration : event.duration_minutes;
435
const eventStart = typeof meta.newStart === 'string'
436
? meta.newStart
437
: typeof meta.proposedStart === 'string'
438
? meta.proposedStart
439
: typeof meta.start === 'string'
440
? meta.start
441
: event.start_time;
442
const oldStart = typeof meta.oldStart === 'string' ? meta.oldStart : null;
444
switch (event.type) {
445
case 'booking_confirmed':
446
return { title: `${name} booked a meeting`, detail: `${duration ?? ''}${duration ? ' min · ' : ''}${eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Confirmed'}` };
447
case 'payment_checkout_started':
448
return { title: `${name} started checkout`, detail: `${money ? `${money} · ` : ''}${eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Payment pending'}` };
449
case 'payment_received':
450
return { title: `Payment received from ${name}`, detail: money ?? 'Stripe payment completed' };
451
case 'payment_hold_released':
452
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' };
453
case 'booking_rescheduled':
454
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'}` };
455
case 'time_suggested':
456
return { title: `New time suggested to ${name}`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Waiting for the guest' };
457
case 'time_suggestion_accepted':
458
return { title: `${name} accepted the suggested time`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Meeting rescheduled' };
459
case 'refund_started':
460
return { title: `Refund started for ${name}`, detail: money ?? 'Stripe is processing the refund' };
461
case 'refund_succeeded':
462
return { title: `Refund completed for ${name}`, detail: money ?? 'Stripe refund completed' };
463
case 'refund_failed':
464
return { title: `Refund failed for ${name}`, detail: money ? `${money} · Check Stripe` : 'Check Stripe for the failure details' };
465
case 'booking_cancelled':
466
return { title: `${name}'s meeting was cancelled`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Booking cancelled' };
467
default:
468
return { title: event.type.replaceAll('_', ' '), detail: event.email ?? 'Slot activity' };
469
}
470
}
472
function monthCells(month: Date): Array<{ date: Date; current: boolean }> {
473
const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
474
const mondayIndex = (first.getDay() + 6) % 7;
475
const start = addDays(first, -mondayIndex);
476
return Array.from({ length: 42 }, (_, index) => {
477
const date = addDays(start, index);
478
return { date, current: date.getMonth() === month.getMonth() };
479
});
480
}
482
function googleCalendarUrl(title: string, conf: Confirmation): string {
483
const stamp = (iso: string) => iso.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
484
const params = new URLSearchParams({
485
action: 'TEMPLATE',
486
text: title,
487
dates: `${stamp(conf.start)}/${stamp(conf.end)}`,
488
});
489
if (conf.meetUrl) params.set('details', `Google Meet: ${conf.meetUrl}`);
490
return `https://calendar.google.com/calendar/render?${params.toString()}`;
491
}
493
function useCopy(): [boolean, (value: string) => void] {
494
const [copied, setCopied] = useState(false);
495
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
496
useEffect(() => () => clearTimeout(timer.current), []);
497
function copy(value: string) {
498
navigator.clipboard?.writeText(value).then(() => {
499
setCopied(true);
500
clearTimeout(timer.current);
501
timer.current = setTimeout(() => setCopied(false), 1800);
502
});
503
}
504
return [copied, copy];
505
}
507
function PublicBooking() {
508
const [config, setConfig] = useState<PublicConfig | null>(null);
509
const [month, setMonth] = useState(() => new Date());
510
const [selectedDate, setSelectedDate] = useState<string>('');
511
const [slots, setSlots] = useState<Slot[]>([]);
512
const [selectedSlot, setSelectedSlot] = useState<Slot | null>(null);
513
const [duration, setDuration] = useState<number | null>(null);
514
const [meetingMode, setMeetingMode] = useState<MeetingMode>('google_meet');
515
const [loadingSlots, setLoadingSlots] = useState(false);
516
const [booking, setBooking] = useState(false);
517
const [error, setError] = useState('');
518
const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string }>({});
519
const [availabilityNotice, setAvailabilityNotice] = useState('');
520
const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
521
const [paymentReturnState, setPaymentReturnState] = useState<{ kind: 'checking' | 'cancelled' | 'error'; message: string } | null>(null);
522
const [manageOpen, setManageOpen] = useState(false);
523
const [manageBookingId, setManageBookingId] = useState('');
524
const [managedBooking, setManagedBooking] = useState<ManagedBooking | null>(null);
525
const [manageAction, setManageAction] = useState<ManageAction>('none');
526
const [manageDate, setManageDate] = useState('');
527
const [manageSlots, setManageSlots] = useState<Array<{ start: string; time: string }>>([]);
528
const [manageStart, setManageStart] = useState('');
529
const [manageSlotsLoading, setManageSlotsLoading] = useState(false);
530
const [manageState, setManageState] = useState<{ kind: 'idle' | 'loading' | 'success' | 'error'; message: string }>({ kind: 'idle', message: '' });
532
useEffect(() => {
533
api<PublicConfig>('/api/config')
534
.then((data) => {
535
setConfig(data);
536
setMeetingMode(data.defaultMeetingMode);
537
document.title = data.title;
538
})
539
.catch((err) => setError(err.message));
540
}, []);
543
useEffect(() => {
544
const params = new URLSearchParams(window.location.search);
545
const payment = params.get('payment');
546
const sessionId = params.get('session_id');
547
const bookingId = params.get('booking_id');
548
const released = params.get('released');
549
let cancelled = false;
550
let timer: ReturnType<typeof setTimeout> | undefined;
552
function cleanReturnUrl() {
553
window.history.replaceState({}, '', window.location.pathname);
554
}
556
async function pollStatus(attempt = 0) {
557
if (!sessionId || cancelled) return;
558
setPaymentReturnState({ kind: 'checking', message: attempt === 0 ? 'Payment received. Confirming your booking…' : 'Payment received. Finalizing your calendar invitation…' });
559
try {
560
const data = await api<{ state: string; message?: string; booking?: Confirmation }>(`/api/payments/status?session_id=${encodeURIComponent(sessionId)}`);
561
if (cancelled) return;
562
if (data.state === 'confirmed' && data.booking) {
563
setConfirmation(data.booking);
564
setMeetingMode(data.booking.meetingMode);
565
setPaymentReturnState(null);
566
cleanReturnUrl();
567
return;
568
}
569
if ((data.state === 'confirming' || data.state === 'processing') && attempt < 20) {
570
setPaymentReturnState({ kind: 'checking', message: data.message || 'Finalizing your booking…' });
571
timer = setTimeout(() => void pollStatus(attempt + 1), 1000);
572
return;
573
}
574
setPaymentReturnState({ kind: 'error', message: data.message || 'Payment did not complete, so no booking was confirmed.' });
575
cleanReturnUrl();
576
} catch (err) {
577
if (cancelled) return;
578
if (attempt < 8) {
579
timer = setTimeout(() => void pollStatus(attempt + 1), 1200);
580
return;
581
}
582
setPaymentReturnState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not verify the payment.' });
583
}
584
}
586
async function releaseCancelledCheckout() {
587
if (!bookingId || cancelled) return;
588
setPaymentReturnState({ kind: 'checking', message: 'Cancelling payment and releasing the time…' });
589
try {
590
const data = await api<{ state: string; booking?: Confirmation }>('/api/payments/cancel', {
591
method: 'POST',
592
body: JSON.stringify({ bookingId }),
593
});
594
if (cancelled) return;
595
if (data.state === 'confirmed' && data.booking) {
596
setConfirmation(data.booking);
597
setMeetingMode(data.booking.meetingMode);
598
setPaymentReturnState(null);
599
} else if (data.state === 'processing') {
600
setPaymentReturnState({ kind: 'checking', message: 'Stripe is processing the payment. Please wait…' });
601
} else {
602
setPaymentReturnState({ kind: 'cancelled', message: 'Payment cancelled. No booking was confirmed and the time has been released.' });
603
}
604
cleanReturnUrl();
605
} catch (err) {
606
if (!cancelled) setPaymentReturnState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not cancel the checkout cleanly.' });
607
}
608
}
610
if (payment === 'success' && sessionId) void pollStatus();
611
else if (payment === 'cancelled' && released === '1') {
612
setPaymentReturnState({ kind: 'cancelled', message: 'Payment cancelled. No booking was confirmed and the time has been released.' });
613
cleanReturnUrl();
614
} else if (payment === 'cancelled' && bookingId) void releaseCancelledCheckout();
616
return () => {
617
cancelled = true;
618
if (timer) clearTimeout(timer);
619
};
620
}, []);
622
const cells = useMemo(() => monthCells(month), [month]);
623
const today = config ? todayInZone(config.timezone) : dateKey(new Date());
624
const maxDate = config ? dateKey(addDays(parseDateKey(today), config.bookingWindowDays)) : today;
626
const isSelectable = (key: string, weekday: number, current: boolean) =>
627
Boolean(config) && current && key >= today && key <= maxDate && config!.availableWeekdays.includes(weekday);
629
async function chooseDate(key: string) {
630
if (!config || key < today || key > maxDate) return;
631
setSelectedDate(key);
632
setSelectedSlot(null);
633
setDuration(null);
634
setConfirmation(null);
635
setSlots([]);
636
setError('');
637
setAvailabilityNotice('');
638
setLoadingSlots(true);
639
try {
640
const data = await api<{ slots: Slot[]; calendarError?: string }>(`/api/availability?date=${encodeURIComponent(key)}`);
641
setSlots(data.slots);
642
setAvailabilityNotice(data.calendarError ?? '');
643
} catch (err) {
644
const message = err instanceof Error ? err.message : 'Could not load availability';
645
if (message.startsWith('Calendar setup needs attention')) setAvailabilityNotice(message);
646
else setError(message);
647
} finally {
648
setLoadingSlots(false);
649
}
650
}
652
async function submitBooking(event: FormEvent<HTMLFormElement>) {
653
event.preventDefault();
654
if (!config || !selectedSlot || !duration) return;
655
const form = new FormData(event.currentTarget);
656
const name = String(form.get('name') ?? '').trim();
657
const email = String(form.get('email') ?? '').trim();
658
const nextErrors: { name?: string; email?: string } = {};
659
if (!name) nextErrors.name = 'Please add your name.';
660
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) nextErrors.email = 'Enter a valid email address.';
661
setFieldErrors(nextErrors);
662
if (Object.keys(nextErrors).length) return;
664
setBooking(true);
665
setError('');
666
try {
667
const data = await api<{ requiresPayment: boolean; checkoutUrl?: string; booking?: Confirmation }>('/api/book', {
668
method: 'POST',
669
body: JSON.stringify({
670
start: selectedSlot.start,
671
duration,
672
meetingMode,
673
name,
674
email,
675
phone: form.get('phone'),
676
message: form.get('message'),
677
website: form.get('website'),
678
}),
679
});
680
if (data.requiresPayment) {
681
if (!data.checkoutUrl) throw new Error('Stripe did not return a secure checkout link.');
682
window.location.assign(data.checkoutUrl);
683
return;
684
}
685
if (!data.booking) throw new Error('The booking response was incomplete.');
686
setConfirmation(data.booking);
687
} catch (err) {
688
const message = err instanceof Error ? err.message : 'Booking failed';
689
setError(message);
690
if (message.toLowerCase().includes('slot') || message.toLowerCase().includes('time')) {
691
await chooseDate(selectedDate);
692
}
693
} finally {
694
setBooking(false);
695
}
696
}
698
async function submitManageLookup(event: FormEvent<HTMLFormElement>) {
699
event.preventDefault();
700
const bookingId = manageBookingId.trim();
701
if (!bookingId) {
702
setManageState({ kind: 'error', message: 'Enter the booking ID from your confirmation or calendar invitation.' });
703
return;
704
}
705
setManageState({ kind: 'loading', message: 'Loading booking…' });
706
setManagedBooking(null);
707
setManageAction('none');
708
setManageSlots([]);
709
setManageStart('');
710
try {
711
const result = await api<{ booking: ManagedBooking }>('/api/bookings/manage', {
712
method: 'POST',
713
body: JSON.stringify({ bookingId }),
714
});
715
setManagedBooking(result.booking);
716
setManageBookingId(result.booking.bookingId);
717
setManageDate(dateKeyForInstant(result.booking.start, result.booking.timezone));
718
setManageState({ kind: 'idle', message: '' });
719
} catch (err) {
720
setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not find this booking.' });
721
}
722
}
724
async function loadManageAvailability(date: string) {
725
if (!managedBooking || !date) return;
726
setManageDate(date);
727
setManageStart('');
728
setManageSlots([]);
729
setManageSlotsLoading(true);
730
setManageState({ kind: 'idle', message: '' });
731
try {
732
const result = await api<{ slots: Array<{ start: string; time: string }> }>('/api/bookings/manage/availability', {
733
method: 'POST',
734
body: JSON.stringify({ bookingId: managedBooking.bookingId, date }),
735
});
736
setManageSlots(result.slots);
737
} catch (err) {
738
setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not load available times.' });
739
} finally {
740
setManageSlotsLoading(false);
741
}
742
}
744
async function cancelManagedBooking() {
745
if (!managedBooking) return;
746
setManageState({ kind: 'loading', message: 'Cancelling booking…' });
747
try {
748
const result = await api<{ ok: boolean; refunded?: boolean; refundStatus?: string; alreadyCancelled?: boolean; warning?: string }>('/api/bookings/cancel', {
749
method: 'POST',
750
body: JSON.stringify({ bookingId: managedBooking.bookingId }),
751
});
752
let message = result.alreadyCancelled
753
? 'This booking was already cancelled.'
754
: result.refunded
755
? result.refundStatus === 'pending'
756
? 'Booking cancelled. Stripe accepted the refund and it is currently pending.'
757
: 'Booking cancelled. The Stripe payment has been refunded.'
758
: 'Booking cancelled. The calendar invitation has been cancelled.';
759
if (result.warning) message += ` ${result.warning}`;
760
setManagedBooking({ ...managedBooking, status: 'cancelled', canCancel: false, canReschedule: false, refundStatus: result.refundStatus ?? managedBooking.refundStatus });
761
setManageAction('none');
762
setManageState({ kind: 'success', message });
763
if (confirmation?.bookingId && confirmation.bookingId.toUpperCase() === managedBooking.bookingId.toUpperCase()) setConfirmation(null);
764
if (selectedDate) await chooseDate(selectedDate);
765
} catch (err) {
766
setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not cancel this booking.' });
767
}
768
}
770
async function rescheduleManagedBooking() {
771
if (!managedBooking || !manageStart) return;
772
setManageState({ kind: 'loading', message: 'Rescheduling booking…' });
773
try {
774
const result = await api<{ ok: boolean; booking: ManagedBooking }>('/api/bookings/reschedule', {
775
method: 'POST',
776
body: JSON.stringify({ bookingId: managedBooking.bookingId, start: manageStart }),
777
});
778
setManagedBooking(result.booking);
779
setManageDate(dateKeyForInstant(result.booking.start, result.booking.timezone));
780
setManageStart('');
781
setManageSlots([]);
782
setManageAction('none');
783
setManageState({ kind: 'success', message: 'Booking rescheduled. Google Calendar sent an updated invitation.' });
784
if (selectedDate) await chooseDate(selectedDate);
785
} catch (err) {
786
setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not reschedule this booking.' });
787
}
788
}
790
if (!config) {
791
return <div className="center-screen"><Loader2 className="spin" /> Loading calendar…</div>;
792
}
794
const activeConfig = config;
795
const bookingStep = confirmation
796
? 'confirmed'
797
: !selectedSlot
798
? 'time'
799
: !duration
800
? 'duration'
801
: 'details';
803
function changeTime() {
804
setSelectedSlot(null);
805
setDuration(null);
806
setConfirmation(null);
807
}
809
function changeDuration() {
810
setDuration(null);
811
setConfirmation(null);
812
}
814
function bookAnotherTime() {
815
setSelectedSlot(null);
816
setDuration(null);
817
setConfirmation(null);
818
setFieldErrors({});
819
setError('');
820
}
822
function renderTimesView() {
823
const groups: Array<{ label: string; items: Slot[] }> = [
824
{ label: 'Morning', items: [] },
825
{ label: 'Afternoon', items: [] },
826
{ label: 'Evening', items: [] },
827
];
828
for (const slot of slots) {
829
const hour = Number(slot.time.slice(0, 2));
830
const bucket = hour < 12 ? 0 : hour < 17 ? 1 : 2;
831
groups[bucket].items.push(slot);
832
}
833
const nonEmpty = groups.filter((group) => group.items.length);
834
const showLabels = slots.length > 6 && nonEmpty.length > 1;
836
return (
837
<div className="panel-view" key="times">
838
<div className="selection-heading">
839
<p className="step-label">02 · PICK A TIME</p>
840
<h2>{prettyDate(selectedDate, activeConfig.timezone)}</h2>
841
</div>
843
{loadingSlots ? (
844
<>
845
<div className="loading-row"><Loader2 className="spin" /> Checking availability…</div>
846
<div className="slot-skeleton" aria-hidden="true">
847
{Array.from({ length: 9 }, (_, i) => <span key={i} />)}
848
</div>
849
</>
850
) : availabilityNotice ? (
851
<div className="no-slots" role="status">{availabilityNotice}</div>
852
) : slots.length === 0 ? (
853
<div className="no-slots" role="status">Nothing open on this day. Try another date.</div>
854
) : (
855
<div className="time-scroll">
856
{nonEmpty.map((group) => (
857
<div className="time-group" key={group.label}>
858
{showLabels && <span className="time-group-label">{group.label}</span>}
859
<div className="time-grid">
860
{group.items.map((slot) => (
861
<button
862
key={slot.start}
863
type="button"
864
className={`time-button ${selectedSlot?.start === slot.start ? 'active' : ''}`}
865
aria-pressed={selectedSlot?.start === slot.start}
866
onClick={() => {
867
setSelectedSlot(slot);
868
setDuration(null);
869
setConfirmation(null);
870
}}
871
>
872
{slot.time}
873
</button>
874
))}
875
</div>
876
</div>
877
))}
878
</div>
879
)}
880
</div>
881
);
882
}
884
function renderDurationView() {
885
if (!selectedSlot) return null;
886
const available = new Set(selectedSlot.durations);
887
const disabledDurations = activeConfig.durations.filter((minutes) => !available.has(minutes));
888
const hasDisabled = disabledDurations.length > 0;
889
const disabledReasons = new Set(disabledDurations.map((minutes) => selectedSlot.blockedDurations?.[String(minutes)] ?? 'unavailable'));
890
const disabledHint = disabledReasons.size === 1 && disabledReasons.has('outside_hours')
891
? "Greyed-out lengths extend past today's booking hours."
892
: disabledReasons.size === 1 && disabledReasons.has('unavailable')
893
? 'Greyed-out lengths run into unavailable calendar time, an availability block, or a configured buffer.'
894
: "Greyed-out lengths either extend past today's booking hours or run into unavailable time.";
895
return (
896
<div className="panel-view" key="duration">
897
<button className="panel-back" type="button" onClick={changeTime}>
898
<ArrowLeft size={16} /> Change time
899
</button>
900
<div className="selected-summary">
901
<span>{prettyDate(selectedDate, activeConfig.timezone)}</span>
902
<strong>{selectedSlot.time}</strong>
903
</div>
904
<div className="duration-picker-view">
905
<p className="step-label">03 · HOW LONG?</p>
906
<div className="duration-grid">
907
{activeConfig.durations.map((minutes) => {
908
const enabled = available.has(minutes);
909
return (
910
<button
911
key={minutes}
912
type="button"
913
className={`duration-button ${duration === minutes ? 'active' : ''}`}
914
disabled={!enabled}
915
aria-pressed={duration === minutes}
916
title={enabled ? undefined : (selectedSlot.blockedDurations?.[String(minutes)] === 'outside_hours' ? "Extends past today's booking hours" : 'Runs into unavailable calendar time or a configured block')}
917
onClick={() => setDuration(minutes)}
918
>
919
<strong>{minutes}</strong>
920
<span>min</span>
921
</button>
922
);
923
})}
924
</div>
925
{hasDisabled && (
926
<p className="duration-hint">{disabledHint}</p>
927
)}
928
</div>
929
</div>
930
);
931
}
933
function renderDetailsView() {
934
if (!selectedSlot || !duration) return null;
935
const paymentRequiredForSelection = Boolean(activeConfig.payment && !activeConfig.payment.freeDurations.includes(duration));
936
return (
937
<div className="panel-view panel-view-scroll" key="details">
938
<button className="panel-back" type="button" onClick={changeDuration}>
939
<ArrowLeft size={16} /> Change duration
940
</button>
941
<div className="selected-summary">
942
<span>{prettyDate(selectedDate, activeConfig.timezone)}</span>
943
<strong>{selectedSlot.time} · {duration} min</strong>
944
</div>
945
<form className="details-form compact-details" onSubmit={submitBooking} noValidate>
946
<p className="step-label">04 · YOUR DETAILS</p>
947
<div className="form-row">
948
<label>Name
949
<input
950
name="name"
951
required
952
autoComplete="name"
953
placeholder="Your name"
954
aria-invalid={fieldErrors.name ? 'true' : undefined}
955
aria-describedby={fieldErrors.name ? 'name-error' : undefined}
956
onInput={() => fieldErrors.name && setFieldErrors((e) => ({ ...e, name: undefined }))}
957
/>
958
{fieldErrors.name && <span className="field-error" id="name-error">{fieldErrors.name}</span>}
959
</label>
960
<label>Email
961
<input
962
name="email"
963
type="email"
964
required
965
autoComplete="email"
966
placeholder="[email protected]"
967
aria-invalid={fieldErrors.email ? 'true' : undefined}
968
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
969
onInput={() => fieldErrors.email && setFieldErrors((e) => ({ ...e, email: undefined }))}
970
/>
971
{fieldErrors.email && <span className="field-error" id="email-error">{fieldErrors.email}</span>}
972
</label>
973
</div>
975
{activeConfig.allowMeetingModes.length > 1 && (
976
<div className="mode-picker">
977
<span className="field-label">Meeting</span>
978
<div className="mode-options" role="radiogroup" aria-label="Meeting method">
979
{activeConfig.allowMeetingModes.map((mode) => {
980
const Icon = modeMeta[mode].icon;
981
return (
982
<button
983
key={mode}
984
type="button"
985
role="radio"
986
aria-checked={meetingMode === mode}
987
className={`mode-option ${meetingMode === mode ? 'active' : ''}`}
988
onClick={() => setMeetingMode(mode)}
989
>
990
<Icon size={18} />
991
<span><strong>{modeMeta[mode].label}</strong><small>{modeMeta[mode].help}</small></span>
992
</button>
993
);
994
})}
995
</div>
996
</div>
997
)}
999
{meetingMode === 'phone' && (
1000
<label>Phone number<input name="phone" required autoComplete="tel" placeholder="+46 ..." /></label>
1001
)}
1002
<label>Anything I should know? <span className="optional">Optional</span>
1003
<textarea name="message" rows={3} placeholder="Context, topic, links..." />
1004
</label>
1005
{activeConfig.payment && paymentRequiredForSelection && (
1006
<div className={`payment-note ${activeConfig.payment.ready ? '' : 'payment-unavailable'}`}>
1007
<CreditCard size={17} />
1008
<div>
1009
<strong>{activeConfig.payment.ready ? 'Payment required to confirm' : 'Payments are temporarily unavailable'}</strong>
1010
<span>
1011
{activeConfig.payment.ready
1012
? <>{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.</>
1013
: <>This session requires payment, but its Stripe connection is not ready. Please contact the owner.</>}
1014
</span>
1015
</div>
1016
</div>
1017
)}
1018
{activeConfig.payment && !paymentRequiredForSelection && (
1019
<div className="payment-note free-session-note">
1020
<Check size={17} />
1021
<div><strong>No payment required</strong><span>{duration}-minute sessions are configured as free.</span></div>
1022
</div>
1023
)}
1024
<label className="honeypot" aria-hidden="true">Website<input name="website" tabIndex={-1} autoComplete="off" /></label>
1026
{error && <div className="error-banner" role="alert">{error}</div>}
1027
<button className="primary-button" type="submit" disabled={booking || Boolean(paymentRequiredForSelection && activeConfig.payment && !activeConfig.payment.ready)}>
1028
{booking
1029
? <><Loader2 className="spin" size={18} /> {paymentRequiredForSelection ? 'Opening payment…' : 'Booking…'}</>
1030
: <>{paymentRequiredForSelection ? (activeConfig.payment?.ready ? 'Continue to secure payment' : 'Payment unavailable') : 'Book this slot'} <ArrowRight size={18} /></>}
1031
</button>
1032
</form>
1033
</div>
1034
);
1035
}
1037
function renderConfirmationView() {
1038
if (!confirmation) return null;
1039
return <ConfirmationView title={activeConfig.title} confirmation={confirmation} onBookAnother={bookAnotherTime} />;
1040
}
1042
function renderRightPanel() {
1043
if (confirmation) return renderConfirmationView();
1044
if (paymentReturnState) {
1045
return (
1046
<div className="panel-view payment-return-panel" key="payment-return">
1047
{paymentReturnState.kind === 'checking' ? <Loader2 className="spin" size={28} /> : paymentReturnState.kind === 'cancelled' ? <CreditCard size={28} /> : <Inbox size={28} />}
1048
<p className="eyebrow">PAYMENT</p>
1049
<h2>{paymentReturnState.kind === 'checking' ? 'Almost done.' : paymentReturnState.kind === 'cancelled' ? 'Payment cancelled.' : 'Booking not confirmed.'}</h2>
1050
<p className="muted">{paymentReturnState.message}</p>
1051
{paymentReturnState.kind !== 'checking' && <button className="text-button" type="button" onClick={() => setPaymentReturnState(null)}>Choose another time</button>}
1052
</div>
1053
);
1054
}
1055
if (!activeConfig.connected) {
1056
return (
1057
<div className="empty-state" key="disconnected">
1058
<MonitorUp size={28} />
1059
<h3>Calendar is not connected yet</h3>
1060
<p>The owner needs to connect Google Calendar before times can be booked.</p>
1061
</div>
1062
);
1063
}
1064
if (!selectedDate) {
1065
return (
1066
<div className="empty-state subtle-empty" key="empty">
1067
<ArrowLeft size={28} />
1068
<h3>Start with the calendar</h3>
1069
<p>Pick a date and available times will appear here.</p>
1070
</div>
1071
);
1072
}
1073
if (bookingStep === 'duration') return renderDurationView();
1074
if (bookingStep === 'details') return renderDetailsView();
1075
return renderTimesView();
1076
}
1078
return (
1079
<main className="booking-page">
1080
<header className="topbar">
1081
<span className="timezone-pill"><Clock3 size={14} /> {activeConfig.timezone}</span>
1082
</header>
1084
<section className="booking-shell">
1085
<aside className="intro-panel">
1086
<div>
1087
<p className="eyebrow">SCHEDULE A MEETING</p>
1088
<h1>{activeConfig.title}</h1>
1089
<p className="intro-copy">{activeConfig.subtitle}</p>
1090
</div>
1091
<div className="intro-bottom">
1092
<div className="intro-note">
1093
<CalendarDays size={18} />
1094
<div>
1095
<strong>Your calendar, not a form maze.</strong>
1096
<span>Choose the day first. Everything else follows.</span>
1097
</div>
1098
</div>
1099
<div className={`public-manage ${manageOpen ? 'open' : ''}`}>
1100
{!manageOpen ? (
1101
<button
1102
className="public-manage-link"
1103
type="button"
1104
onClick={() => {
1105
setManageOpen(true);
1106
setManageState({ kind: 'idle', message: '' });
1107
}}
1108
>Manage a booking</button>
1109
) : !managedBooking ? (
1110
<form className="public-manage-form" onSubmit={submitManageLookup}>
1111
<div className="public-manage-heading">
1112
<strong>Manage booking</strong>
1113
<button className="text-button" type="button" onClick={() => setManageOpen(false)}>Close</button>
1114
</div>
1115
<label>
1116
Booking ID
1117
<input
1118
value={manageBookingId}
1119
onChange={(event) => {
1120
setManageBookingId(event.target.value.toUpperCase());
1121
if (manageState.kind !== 'loading') setManageState({ kind: 'idle', message: '' });
1122
}}
1123
autoComplete="off"
1124
spellCheck={false}
1125
placeholder="SLT-7K4Q-9M2P-W8RX-3FJN"
1126
aria-label="Booking ID"
1127
/>
1128
</label>
1129
<button className="secondary-button public-manage-submit" type="submit" disabled={manageState.kind === 'loading'}>
1130
{manageState.kind === 'loading' ? <><Loader2 className="spin" size={15} /> Loading…</> : 'Manage booking'}
1131
</button>
1132
<small>Your booking ID is shown on the confirmation screen and in the calendar invitation.</small>
1133
{manageState.kind === 'error' && <p className="public-manage-message error" role="alert">{manageState.message}</p>}
1134
</form>
1135
) : (
1136
<div className="public-manage-form">
1137
<div className="public-manage-heading">
1138
<strong>Manage booking</strong>
1139
<button className="text-button" type="button" onClick={() => {
1140
setManagedBooking(null);
1141
setManageAction('none');
1142
setManageState({ kind: 'idle', message: '' });
1143
}}>Another ID</button>
1144
</div>
1145
<div className="public-manage-summary">
1146
<code>{managedBooking.bookingId}</code>
1147
<strong>{managedBooking.name}</strong>
1148
<span>{formatDateShort(managedBooking.start, managedBooking.timezone)} · {formatClock(managedBooking.start, managedBooking.timezone)} · {managedBooking.duration} min</span>
1149
{managedBooking.status !== 'confirmed' && <span className="public-manage-status">{managedBooking.status === 'cancelled' ? 'Cancelled' : managedBooking.status}</span>}
1150
</div>
1152
{manageAction === 'none' && managedBooking.status === 'confirmed' && (
1153
<div className="public-manage-actions">
1154
<button type="button" className="secondary-button" disabled={!managedBooking.canReschedule} onClick={() => {
1155
setManageAction('reschedule');
1156
const date = dateKeyForInstant(managedBooking.start, managedBooking.timezone);
1157
void loadManageAvailability(date);
1158
}}>Reschedule</button>
1159
<button type="button" className="danger-button" disabled={!managedBooking.canCancel} onClick={() => setManageAction('cancel')}>Cancel</button>
1160
</div>
1161
)}
1163
{manageAction === 'cancel' && (
1164
<div className="public-manage-action-panel">
1165
<strong>Cancel this booking?</strong>
1166
<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>
1167
<div className="public-manage-actions">
1168
<button type="button" className="secondary-button" onClick={() => setManageAction('none')} disabled={manageState.kind === 'loading'}>Keep booking</button>
1169
<button type="button" className="danger-button" onClick={cancelManagedBooking} disabled={manageState.kind === 'loading'}>{manageState.kind === 'loading' ? 'Cancelling…' : managedBooking.paymentStatus === 'paid' ? 'Cancel & refund' : 'Confirm cancellation'}</button>
1170
</div>
1171
</div>
1172
)}
1174
{manageAction === 'reschedule' && (
1175
<div className="public-manage-action-panel">
1176
<label>New date<input type="date" value={manageDate} onChange={(event) => void loadManageAvailability(event.target.value)} /></label>
1177
{manageSlotsLoading ? <div className="public-manage-loading"><Loader2 className="spin" size={14} /> Checking times…</div> : manageSlots.length ? (
1178
<div className="public-manage-times">
1179
{manageSlots.map((slot) => <button type="button" key={slot.start} className={manageStart === slot.start ? 'active' : ''} onClick={() => setManageStart(slot.start)}>{slot.time}</button>)}
1180
</div>
1181
) : <small>No matching {managedBooking.duration}-minute times are open on this date.</small>}
1182
<div className="public-manage-actions">
1183
<button type="button" className="secondary-button" onClick={() => { setManageAction('none'); setManageStart(''); }} disabled={manageState.kind === 'loading'}>Back</button>
1184
<button type="button" className="primary-button" onClick={rescheduleManagedBooking} disabled={!manageStart || manageState.kind === 'loading'}>{manageState.kind === 'loading' ? 'Rescheduling…' : 'Reschedule booking'}</button>
1185
</div>
1186
</div>
1187
)}
1189
{manageState.kind === 'success' && <p className="public-manage-message success" role="status">{manageState.message}</p>}
1190
{manageState.kind === 'error' && <p className="public-manage-message error" role="alert">{manageState.message}</p>}
1191
</div>
1192
)}
1193
</div>
1194
</div>
1195
</aside>
1197
<section className="calendar-panel" aria-label="Choose a date">
1198
<div className="calendar-toolbar">
1199
<div>
1200
<p className="step-label">01 · PICK A DAY</p>
1201
<h2 aria-live="polite">{month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</h2>
1202
</div>
1203
<div className="month-buttons">
1204
<button type="button" aria-label="Previous month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><ChevronLeft /></button>
1205
<button type="button" aria-label="Next month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><ChevronRight /></button>
1206
</div>
1207
</div>
1209
<div className="weekday-grid" aria-hidden="true">
1210
{shortDayNames.map((name) => <span key={name}>{name}</span>)}
1211
</div>
1212
<div className="calendar-grid">
1213
{cells.map(({ date, current }) => {
1214
const key = dateKey(date);
1215
const selectable = isSelectable(key, date.getDay(), current);
1216
const selected = key === selectedDate;
1217
const isToday = key === today;
1218
return (
1219
<button
1220
key={key}
1221
type="button"
1222
className={`day ${current ? '' : 'outside'} ${selected ? 'selected' : ''} ${isToday ? 'today' : ''}`}
1223
disabled={!selectable}
1224
aria-pressed={selected}
1225
aria-label={dayAriaLabel(date)}
1226
onClick={() => chooseDate(key)}
1227
>
1228
<span>{date.getDate()}</span>
1229
{isToday && <small>Today</small>}
1230
</button>
1231
);
1232
})}
1233
</div>
1234
</section>
1236
<aside className="selection-panel" aria-live="polite">{renderRightPanel()}</aside>
1237
</section>
1239
{error && !(selectedSlot && duration) && <div className="floating-error" role="alert">{error}</div>}
1240
<footer>
1241
<a className="powered-by" href="https://www.nobgit.com/user/imalexnord/slot" target="_blank" rel="noreferrer">Powered by Slot</a>
1242
</footer>
1243
</main>
1244
);
1245
}
1247
function ConfirmationView({ title, confirmation, onBookAnother }: { title: string; confirmation: Confirmation; onBookAnother: () => void }) {
1248
const [copied, copy] = useCopy();
1249
const tz = confirmation.timezone;
1251
return (
1252
<div className="panel-view confirmation-panel panel-view-scroll" key="confirmed">
1253
<span className="success-icon"><Check /></span>
1254
<p className="eyebrow">YOU'RE BOOKED</p>
1255
<h2>See you then.</h2>
1257
<dl className="confirmation-details">
1258
<div className="confirm-row"><dt>Date</dt><dd>{formatDateShort(confirmation.start, tz)}</dd></div>
1259
<div className="confirm-row"><dt>Time</dt><dd>{formatClock(confirmation.start, tz)} – {formatClock(confirmation.end, tz)}</dd></div>
1260
<div className="confirm-row"><dt>Length</dt><dd>{confirmation.duration} min</dd></div>
1261
{confirmation.bookingId && <div className="confirm-row booking-id-row"><dt>Booking ID</dt><dd>{confirmation.bookingId}</dd></div>}
1262
<div className="confirm-row"><dt>Timezone</dt><dd>{tz}</dd></div>
1263
<div className="confirm-row"><dt>Meeting</dt><dd>{modeMeta[confirmation.meetingMode].label}</dd></div>
1264
</dl>
1266
<p className="muted">Your booking is confirmed. A calendar invitation has been sent to your email.</p>
1268
<div className="confirm-actions">
1269
<a className="primary-button" href={googleCalendarUrl(title, confirmation)} target="_blank" rel="noreferrer">
1270
<CalendarPlus size={16} /> Add to calendar
1271
</a>
1272
{confirmation.receiptUrl && (
1273
<a className="secondary-button" href={confirmation.receiptUrl} target="_blank" rel="noreferrer">
1274
View Stripe receipt <ExternalLink size={15} />
1275
</a>
1276
)}
1277
{confirmation.meetUrl && (
1278
<>
1279
<a className="secondary-button" href={confirmation.meetUrl} target="_blank" rel="noreferrer">
1280
Open Google Meet <ExternalLink size={15} />
1281
</a>
1282
<button
1283
type="button"
1284
className={`copy-button ${copied ? 'copied' : ''}`}
1285
onClick={() => copy(confirmation.meetUrl!)}
1286
>
1287
{copied ? <><Check size={15} /> Link copied</> : <><Copy size={15} /> Copy Meet link</>}
1288
</button>
1289
</>
1290
)}
1291
<button className="text-button" type="button" onClick={onBookAnother}>Book another time</button>
1292
</div>
1293
</div>
1294
);
1295
}
1298
function RescheduleSuggestionPage() {
1299
const token = decodeURIComponent(window.location.pathname.split('/').filter(Boolean)[1] ?? '');
1300
const [proposal, setProposal] = useState<RescheduleProposal | null>(null);
1301
const [accepted, setAccepted] = useState<Confirmation | null>(null);
1302
const [loading, setLoading] = useState(true);
1303
const [accepting, setAccepting] = useState(false);
1304
const [error, setError] = useState('');
1306
useEffect(() => {
1307
api<RescheduleProposal>(`/api/reschedule-suggestions/${encodeURIComponent(token)}`)
1308
.then((data) => {
1309
setProposal(data);
1310
document.title = `Reschedule · ${data.title}`;
1311
})
1312
.catch((err) => setError(err instanceof Error ? err.message : 'Could not load this suggestion.'))
1313
.finally(() => setLoading(false));
1314
}, [token]);
1316
async function acceptSuggestion() {
1317
setAccepting(true);
1318
setError('');
1319
try {
1320
const result = await api<{ booking: Confirmation }>(`/api/reschedule-suggestions/${encodeURIComponent(token)}/accept`, {
1321
method: 'POST',
1322
});
1323
setAccepted(result.booking);
1324
} catch (err) {
1325
setError(err instanceof Error ? err.message : 'Could not accept the new time.');
1326
} finally {
1327
setAccepting(false);
1328
}
1329
}
1331
if (loading) return <div className="center-screen"><Loader2 className="spin" /> Loading suggestion…</div>;
1332
if (accepted && proposal) {
1333
return (
1334
<main className="admin-login-shell">
1335
<section className="admin-login-card reschedule-public-card">
1336
<ConfirmationView title={proposal.title} confirmation={accepted} onBookAnother={() => { window.location.href = '/'; }} />
1337
</section>
1338
</main>
1339
);
1340
}
1341
if (!proposal) {
1342
return (
1343
<main className="admin-login-shell">
1344
<section className="admin-login-card">
1345
<span className="admin-login-icon"><CalendarDays /></span>
1346
<p className="eyebrow">RESCHEDULE</p>
1347
<h1>Suggestion unavailable.</h1>
1348
<p>{error || 'This reschedule link has expired or was already used.'}</p>
1349
</section>
1350
</main>
1351
);
1352
}
1354
return (
1355
<main className="admin-login-shell">
1356
<section className="admin-login-card reschedule-public-card">
1357
<span className="admin-login-icon"><CalendarDays /></span>
1358
<p className="eyebrow">NEW TIME SUGGESTED</p>
1359
<h1>Does this time work?</h1>
1360
<p>Hi {proposal.name}. The organizer suggested moving your meeting. Your current time stays booked until you accept.</p>
1361
<div className="schedule-compare">
1362
<div>
1363
<span>Current</span>
1364
<strong>{formatDateShort(proposal.currentStart, proposal.timezone)}</strong>
1365
<small>{formatClock(proposal.currentStart, proposal.timezone)} – {formatClock(proposal.currentEnd, proposal.timezone)}</small>
1366
</div>
1367
<ArrowRight size={18} />
1368
<div className="proposed">
1369
<span>Suggested</span>
1370
<strong>{formatDateShort(proposal.proposedStart, proposal.timezone)}</strong>
1371
<small>{formatClock(proposal.proposedStart, proposal.timezone)} – {formatClock(proposal.proposedEnd, proposal.timezone)}</small>
1372
</div>
1373
</div>
1374
<p className="muted">{proposal.duration} min · {proposal.timezone} · {modeMeta[proposal.meetingMode].label}</p>
1375
{error && <div className="error-banner" role="alert">{error}</div>}
1376
<button className="primary-button inline-button" type="button" onClick={acceptSuggestion} disabled={accepting}>
1377
{accepting ? <><Loader2 className="spin" size={17} /> Rescheduling…</> : <>Accept new time <ArrowRight size={17} /></>}
1378
</button>
1379
</section>
1380
</main>
1381
);
1382
}
1384
function BookingRowItem({
1385
booking,
1386
timezone,
1387
onOpen,
1388
}: {
1389
booking: BookingRow;
1390
timezone: string;
1391
onOpen: (booking: BookingRow) => void;
1392
}) {
1393
const [copied, copy] = useCopy();
1394
const isCancelled = booking.status === 'cancelled';
1395
const isPaymentHold = booking.status === 'pending' && booking.payment_status !== 'paid';
1396
const isPast = Date.parse(booking.end_time) < Date.now() || isCancelled;
1397
const Icon = modeMeta[booking.meeting_mode].icon;
1398
return (
1399
<div className={`booking-list-row ${isPast ? 'past' : ''} ${isCancelled ? 'cancelled' : ''}`}>
1400
<div className="booking-avatar" aria-hidden="true">{booking.name.slice(0, 1).toUpperCase()}</div>
1401
<div className="booking-person">
1402
<strong>{booking.name}</strong>
1403
<span>{booking.email}</span>
1404
</div>
1405
<div className="booking-when">
1406
<strong>{formatDateShort(booking.start_time, timezone)}</strong>
1407
<span>{formatClock(booking.start_time, timezone)} – {formatClock(booking.end_time, timezone)}</span>
1408
</div>
1409
<div className="booking-meta">
1410
<span className="booking-method"><Icon size={13} /> {modeMeta[booking.meeting_mode].label}</span>
1411
<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>
1412
</div>
1413
<div className="booking-actions">
1414
<span className={`status-pill ${isCancelled ? 'cancelled' : isPast ? 'past' : 'upcoming'}`}>
1415
{isCancelled ? 'Cancelled' : isPaymentHold ? 'Payment hold' : isPast ? 'Past' : 'Upcoming'}
1416
</span>
1417
<button type="button" className="icon-button" aria-label="Open booking details" title="Open details" onClick={() => onOpen(booking)}>
1418
<Inbox size={15} />
1419
</button>
1420
{booking.status === 'confirmed' && booking.meet_url && (
1421
<>
1422
<button
1423
type="button"
1424
className={`icon-button ${copied ? 'copied' : ''}`}
1425
aria-label="Copy Meet link"
1426
title="Copy Meet link"
1427
onClick={() => copy(booking.meet_url!)}
1428
>
1429
{copied ? <Check size={15} /> : <Copy size={15} />}
1430
</button>
1431
<a className="icon-button" href={booking.meet_url} target="_blank" rel="noreferrer" aria-label="Open Google Meet" title="Open Google Meet">
1432
<Video size={15} />
1433
</a>
1434
</>
1435
)}
1436
</div>
1437
</div>
1438
);
1439
}
1441
function Admin() {
1442
const [session, setSession] = useState<AdminSession | null>(null);
1443
const [settings, setSettings] = useState<AppSettings | null>(null);
1444
const [bookings, setBookings] = useState<BookingRow[]>([]);
1445
const [recurringUnavailable, setRecurringUnavailable] = useState<RecurringUnavailableRule[]>([]);
1446
const [availabilityBlocks, setAvailabilityBlocks] = useState<AvailabilityBlock[]>([]);
1447
const [activity, setActivity] = useState<ActivityData | null>(null);
1448
const [activityLoading, setActivityLoading] = useState(false);
1449
const [attendeeData, setAttendeeData] = useState<AttendeeData | null>(null);
1450
const [attendeesLoading, setAttendeesLoading] = useState(false);
1451
const [attendeeSearch, setAttendeeSearch] = useState('');
1452
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeRow | null>(null);
1453
const [attendeeBookings, setAttendeeBookings] = useState<BookingRow[]>([]);
1454
const [attendeeBookingsLoading, setAttendeeBookingsLoading] = useState(false);
1455
const [tab, setTab] = useState<'settings' | 'bookings' | 'activity' | 'attendees'>('bookings');
1456
const [bookingFilter, setBookingFilter] = useState<'upcoming' | 'past'>('upcoming');
1457
const [bookingSearch, setBookingSearch] = useState('');
1458
const [bookingSearchResults, setBookingSearchResults] = useState<BookingRow[] | null>(null);
1459
const [bookingSearchLoading, setBookingSearchLoading] = useState(false);
1460
const [selectedBooking, setSelectedBooking] = useState<BookingRow | null>(null);
1461
const [cancelTarget, setCancelTarget] = useState<BookingRow | null>(null);
1462
const [cancelling, setCancelling] = useState(false);
1463
const [scheduleTarget, setScheduleTarget] = useState<BookingRow | null>(null);
1464
const [scheduleAction, setScheduleAction] = useState<BookingScheduleAction>('reschedule');
1465
const [scheduleDate, setScheduleDate] = useState('');
1466
const [scheduleSlots, setScheduleSlots] = useState<Slot[]>([]);
1467
const [scheduleStart, setScheduleStart] = useState('');
1468
const [loadingScheduleSlots, setLoadingScheduleSlots] = useState(false);
1469
const [savingScheduleAction, setSavingScheduleAction] = useState(false);
1470
const [suggestionUrl, setSuggestionUrl] = useState('');
1471
const [scheduleLinkCopied, copyScheduleLink] = useCopy();
1472
const [recurringForm, setRecurringForm] = useState({ weekdays: [1, 2, 3, 4, 5], start: '12:00', end: '13:00', label: 'Lunch' });
1473
const [blockForm, setBlockForm] = useState({ date: '', start: '09:00', end: '12:00', allDay: false, label: '' });
1474
const [theme, setTheme] = useState<ThemeId>(() => {
1475
const stored = localStorage.getItem('meet-theme') as ThemeId | null;
1476
return stored && themes.some((item) => item.id === stored) ? stored : 'true-black';
1477
});
1478
const [saving, setSaving] = useState(false);
1479
const [notice, setNotice] = useState('');
1480
const [error, setError] = useState('');
1481
const [stripeConfigured, setStripeConfigured] = useState(false);
1482
const [stripeMode, setStripeMode] = useState<'test' | 'live' | 'unknown'>('unknown');
1484
useEffect(() => {
1485
document.title = 'Bookings';
1486
}, []);
1488
useEffect(() => {
1489
document.documentElement.dataset.theme = theme;
1490
localStorage.setItem('meet-theme', theme);
1491
}, [theme]);
1493
useEffect(() => {
1494
fetch('/api/admin/session')
1495
.then(async (response) => {
1496
if (!response.ok) throw new Error('unauthenticated');
1497
return response.json() as Promise<AdminSession>;
1498
})
1499
.then((data) => setSession(data))
1500
.catch(() => setSession({ authenticated: false, email: '', connected: false }));
1501
}, []);
1503
useEffect(() => {
1504
if (!session?.authenticated) return;
1505
Promise.all([
1506
api<{ settings: AppSettings; stripeConfigured: boolean; stripeMode: 'test' | 'live' | 'unknown' }>('/api/admin/settings'),
1507
api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
1508
api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
1509
]).then(([settingsData, bookingData, availabilityData]) => {
1510
setSettings(settingsData.settings);
1511
setStripeConfigured(settingsData.stripeConfigured);
1512
setStripeMode(settingsData.stripeMode);
1513
setBookings(bookingData.bookings);
1514
setRecurringUnavailable(availabilityData.recurring);
1515
setAvailabilityBlocks(availabilityData.blocks);
1516
}).catch((err) => setError(err.message));
1517
}, [session]);
1520
useEffect(() => {
1521
if (!session?.authenticated || tab !== 'activity') return;
1522
void loadActivity();
1523
}, [session?.authenticated, tab]);
1525
useEffect(() => {
1526
if (!session?.authenticated || tab !== 'attendees') return;
1527
void loadAttendees();
1528
}, [session?.authenticated, tab]);
1530
useEffect(() => {
1531
if (!session?.authenticated || tab !== 'bookings') return;
1532
const query = bookingSearch.trim();
1533
if (!query) {
1534
setBookingSearchResults(null);
1535
setBookingSearchLoading(false);
1536
return;
1537
}
1538
let cancelled = false;
1539
setError('');
1540
setBookingSearchResults(null);
1541
const timer = window.setTimeout(() => {
1542
setBookingSearchLoading(true);
1543
api<{ bookings: BookingRow[] }>(`/api/admin/bookings?q=${encodeURIComponent(query)}`)
1544
.then((data) => { if (!cancelled) setBookingSearchResults(data.bookings); })
1545
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Could not search bookings.'); })
1546
.finally(() => { if (!cancelled) setBookingSearchLoading(false); });
1547
}, 180);
1548
return () => { cancelled = true; window.clearTimeout(timer); };
1549
}, [session?.authenticated, tab, bookingSearch]);
1551
useEffect(() => {
1552
if (!scheduleTarget || !scheduleDate) {
1553
setScheduleSlots([]);
1554
return;
1555
}
1556
let cancelled = false;
1557
setLoadingScheduleSlots(true);
1558
setScheduleStart('');
1559
api<{ slots: Slot[] }>(`/api/admin/bookings/${scheduleTarget.id}/availability?date=${encodeURIComponent(scheduleDate)}`)
1560
.then((data) => {
1561
if (!cancelled) setScheduleSlots(data.slots);
1562
})
1563
.catch((err) => {
1564
if (!cancelled) {
1565
setScheduleSlots([]);
1566
setError(err instanceof Error ? err.message : 'Could not load available times.');
1567
}
1568
})
1569
.finally(() => {
1570
if (!cancelled) setLoadingScheduleSlots(false);
1571
});
1572
return () => { cancelled = true; };
1573
}, [scheduleTarget?.id, scheduleDate]);
1575
function openScheduleDialog(booking: BookingRow, action: BookingScheduleAction) {
1576
if (!settings) return;
1577
setScheduleTarget(booking);
1578
setScheduleAction(action);
1579
setScheduleDate(dateKeyForInstant(booking.start_time, settings.timezone));
1580
setScheduleStart('');
1581
setScheduleSlots([]);
1582
setSuggestionUrl('');
1583
setError('');
1584
}
1586
async function submitScheduleAction() {
1587
if (!scheduleTarget || !scheduleStart) return;
1588
setSavingScheduleAction(true);
1589
setError('');
1590
try {
1591
if (scheduleAction === 'reschedule') {
1592
await api(`/api/admin/bookings/${scheduleTarget.id}/reschedule`, {
1593
method: 'POST',
1594
body: JSON.stringify({ start: scheduleStart }),
1595
});
1596
await refreshAdminData();
1597
setScheduleTarget(null);
1598
setSelectedBooking(null);
1599
setNotice('Meeting rescheduled. Google Calendar sent the attendee an updated invitation.');
1600
} else {
1601
const result = await api<{ suggestionUrl: string }>(`/api/admin/bookings/${scheduleTarget.id}/suggest`, {
1602
method: 'POST',
1603
body: JSON.stringify({ start: scheduleStart }),
1604
});
1605
await refreshAdminData();
1606
setSuggestionUrl(result.suggestionUrl);
1607
setNotice('New time suggested. Google Calendar notified the attendee and the current meeting remains booked until they accept.');
1608
}
1609
} catch (err) {
1610
setError(err instanceof Error ? err.message : 'Could not update the meeting.');
1611
} finally {
1612
setSavingScheduleAction(false);
1613
}
1614
}
1616
async function loadActivity() {
1617
setActivityLoading(true);
1618
setError('');
1619
try {
1620
setActivity(await api<ActivityData>('/api/admin/activity'));
1621
} catch (err) {
1622
setError(err instanceof Error ? err.message : 'Could not load activity.');
1623
} finally {
1624
setActivityLoading(false);
1625
}
1626
}
1628
async function loadAttendees() {
1629
setAttendeesLoading(true);
1630
setError('');
1631
try {
1632
setAttendeeData(await api<AttendeeData>('/api/admin/attendees'));
1633
} catch (err) {
1634
setError(err instanceof Error ? err.message : 'Could not load attendees.');
1635
} finally {
1636
setAttendeesLoading(false);
1637
}
1638
}
1640
async function openAttendee(attendee: AttendeeRow) {
1641
setSelectedAttendee(attendee);
1642
setAttendeeBookings([]);
1643
setAttendeeBookingsLoading(true);
1644
setError('');
1645
try {
1646
const data = await api<{ bookings: BookingRow[] }>(`/api/admin/attendee-bookings?email=${encodeURIComponent(attendee.email)}`);
1647
setAttendeeBookings(data.bookings);
1648
} catch (err) {
1649
setError(err instanceof Error ? err.message : 'Could not load attendee history.');
1650
} finally {
1651
setAttendeeBookingsLoading(false);
1652
}
1653
}
1655
function openBookingFromAttendee(booking: BookingRow) {
1656
setTab('bookings');
1657
setBookingSearch(booking.public_booking_id || booking.id);
1658
setSelectedBooking(booking);
1659
}
1661
async function refreshAdminData() {
1662
const query = bookingSearch.trim();
1663
const [bookingData, availabilityData, searchedData] = await Promise.all([
1664
api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
1665
api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
1666
query ? api<{ bookings: BookingRow[] }>(`/api/admin/bookings?q=${encodeURIComponent(query)}`) : Promise.resolve(null),
1667
]);
1668
setBookings(bookingData.bookings);
1669
if (searchedData) setBookingSearchResults(searchedData.bookings);
1670
setRecurringUnavailable(availabilityData.recurring);
1671
setAvailabilityBlocks(availabilityData.blocks);
1672
if (attendeeData) void loadAttendees();
1673
}
1675
async function save() {
1676
if (!settings) return;
1677
setSaving(true);
1678
setError('');
1679
setNotice('');
1680
try {
1681
const data = await api<{ settings: AppSettings }>('/api/admin/settings', {
1682
method: 'PUT',
1683
body: JSON.stringify(settings),
1684
});
1685
setSettings(data.settings);
1686
setNotice('Saved. The public calendar is using these settings now.');
1687
} catch (err) {
1688
setError(err instanceof Error ? err.message : 'Could not save settings');
1689
} finally {
1690
setSaving(false);
1691
}
1692
}
1694
async function addRecurringUnavailable() {
1695
setError('');
1696
try {
1697
await api('/api/admin/availability-rules/recurring', {
1698
method: 'POST',
1699
body: JSON.stringify(recurringForm),
1700
});
1701
await refreshAdminData();
1702
setNotice('Recurring unavailable time added.');
1703
} catch (err) {
1704
setError(err instanceof Error ? err.message : 'Could not add unavailable time');
1705
}
1706
}
1708
async function addAvailabilityBlock() {
1709
setError('');
1710
try {
1711
await api('/api/admin/availability-rules/blocks', {
1712
method: 'POST',
1713
body: JSON.stringify(blockForm),
1714
});
1715
await refreshAdminData();
1716
setBlockForm({ date: '', start: '09:00', end: '12:00', allDay: false, label: '' });
1717
setNotice('Blocked time added.');
1718
} catch (err) {
1719
setError(err instanceof Error ? err.message : 'Could not add blocked time');
1720
}
1721
}
1723
async function deleteAvailabilityRule(kind: 'recurring' | 'block', id: string) {
1724
setError('');
1725
try {
1726
await api(`/api/admin/availability-rules/${kind}/${id}`, { method: 'DELETE' });
1727
await refreshAdminData();
1728
setNotice('Availability rule removed.');
1729
} catch (err) {
1730
setError(err instanceof Error ? err.message : 'Could not remove availability rule');
1731
}
1732
}
1734
async function confirmCancelBooking() {
1735
if (!cancelTarget) return;
1736
const releasingPaymentHold = cancelTarget.status === 'pending';
1737
setCancelling(true);
1738
setError('');
1739
try {
1740
const result = await api<{ warning?: string; refunded?: boolean; refundStatus?: string }>(`/api/admin/bookings/${cancelTarget.id}/cancel`, {
1741
method: 'POST',
1742
body: JSON.stringify({ allowStaleStripeRelease: releasingPaymentHold }),
1743
});
1744
setCancelTarget(null);
1745
setSelectedBooking(null);
1746
await refreshAdminData();
1747
setNotice(releasingPaymentHold
1748
? result.warning
1749
? `Payment hold released. The time is available again. ${result.warning}`
1750
: 'Payment hold released. The time is available again.'
1751
: result.refunded
1752
? `Meeting cancelled and Stripe refund ${result.refundStatus === 'pending' ? 'started' : 'issued'}.${result.warning ? ` ${result.warning}` : ''}`
1753
: `Meeting cancelled. Google Calendar will notify the attendee.${result.warning ? ` ${result.warning}` : ''}`);
1754
} catch (err) {
1755
setError(err instanceof Error ? err.message : 'Could not cancel meeting');
1756
} finally {
1757
setCancelling(false);
1758
}
1759
}
1761
if (session === null) return <div className="center-screen"><Loader2 className="spin" /> Loading admin…</div>;
1763
if (!session.authenticated) {
1764
const params = new URLSearchParams(window.location.search);
1765
const oauthError = params.get('oauth_error');
1766
return (
1767
<main className="admin-login-shell">
1768
<section className="admin-login-card">
1769
<span className="admin-login-icon"><Settings2 /></span>
1770
<p className="eyebrow">PRIVATE</p>
1771
<h1>Control your availability.</h1>
1772
<p>Google login is only for the calendar owner. People booking you never need an account.</p>
1773
{oauthError && <div className="error-banner" role="alert">{oauthError}</div>}
1774
<a className="primary-button inline-button" href="/auth/google/start">Continue with Google <ArrowRight size={18} /></a>
1775
<a className="text-button" href="/">Back to booking page</a>
1776
</section>
1777
</main>
1778
);
1779
}
1781
if (!settings) return <div className="center-screen"><Loader2 className="spin" /> Loading settings…</div>;
1783
function setDay(day: number, patch: Partial<{ enabled: boolean; start: string; end: string }>) {
1784
setSettings((current) => {
1785
if (!current) return current;
1786
const existing = current.availability[String(day)]?.[0] ?? { start: '09:00', end: '17:00' };
1787
const enabled = patch.enabled ?? Boolean(current.availability[String(day)]?.length);
1788
return {
1789
...current,
1790
availability: {
1791
...current.availability,
1792
[String(day)]: enabled ? [{ start: patch.start ?? existing.start, end: patch.end ?? existing.end }] : [],
1793
},
1794
};
1795
});
1796
}
1798
const activeSettings = settings;
1799
const now = Date.now();
1800
const bookingSource = bookingSearch.trim() ? (bookingSearchResults ?? []) : bookings;
1801
const upcoming = bookingSource.filter((b) => b.status !== 'cancelled' && Date.parse(b.end_time) >= now);
1802
const past = bookingSource.filter((b) => b.status === 'cancelled' || Date.parse(b.end_time) < now);
1803
const shown = bookingFilter === 'upcoming' ? upcoming : past;
1804
const attendeeNeedle = attendeeSearch.trim().toLowerCase();
1805
const shownAttendees = (attendeeData?.attendees ?? []).filter((attendee) => !attendeeNeedle
1806
|| attendee.name.toLowerCase().includes(attendeeNeedle)
1807
|| attendee.email.toLowerCase().includes(attendeeNeedle));
1809
return (
1810
<main className="admin-page">
1811
<header className="admin-topbar">
1812
<div className="admin-user">
1813
<span>{session.email}</span>
1814
<button onClick={async () => { await api('/api/admin/logout', { method: 'POST' }); window.location.reload(); }}><LogOut size={16} /> Log out</button>
1815
</div>
1816
</header>
1818
<div className="admin-layout">
1819
<aside className="admin-nav">
1820
<button className={tab === 'bookings' ? 'active' : ''} onClick={() => setTab('bookings')}><CalendarDays size={17} /> Bookings</button>
1821
<button className={tab === 'settings' ? 'active' : ''} onClick={() => setTab('settings')}><Settings2 size={17} /> Settings</button>
1822
<button className={tab === 'activity' ? 'active' : ''} onClick={() => setTab('activity')}><Activity size={17} /> Activity</button>
1823
<button className={tab === 'attendees' ? 'active' : ''} onClick={() => setTab('attendees')}><Users size={17} /> Attendees</button>
1824
<a href="/" target="_blank"><ExternalLink size={17} /> Open booking page</a>
1825
</aside>
1827
<section className="admin-content">
1828
{tab === 'settings' ? (
1829
<>
1830
<div className="admin-heading">
1831
<div><p className="eyebrow">SETTINGS</p><h1>Keep it simple.</h1></div>
1832
<button className="primary-button compact" onClick={save} disabled={saving}>{saving ? <Loader2 className="spin" /> : <Check />} Save changes</button>
1833
</div>
1834
{notice && <div className="success-banner" role="status">{notice}</div>}
1835
{error && <div className="error-banner" role="alert">{error}</div>}
1837
<div className="settings-grid">
1838
<section className="settings-card wide-card appearance-card">
1839
<div className="appearance-heading">
1840
<div>
1841
<p className="eyebrow">APPEARANCE</p>
1842
<h2>Theme and contrast</h2>
1843
<p>Choose how this app looks on this browser. Your selection is saved locally and applied before each page is shown.</p>
1844
</div>
1845
<span>{themes.find((item) => item.id === theme)?.name} selected</span>
1846
</div>
1847
<div className="theme-grid">
1848
{themes.map((item) => (
1849
<button
1850
type="button"
1851
key={item.id}
1852
className={`theme-option ${theme === item.id ? 'active' : ''}`}
1853
aria-pressed={theme === item.id}
1854
onClick={() => setTheme(item.id)}
1855
>
1856
<span className={`theme-preview theme-preview-${item.id}`} aria-hidden="true">
1857
<span />
1858
</span>
1859
<span className="theme-copy">
1860
<strong>{item.name}</strong>
1861
<small>{item.description}</small>
1862
<em>{item.contrast}</em>
1863
</span>
1864
</button>
1865
))}
1866
</div>
1867
<p className="theme-footnote">Theme settings stay on this device and do not change your booking page for other visitors.</p>
1868
</section>
1870
<section className="settings-card wide-card">
1871
<div className="card-heading"><h2>Availability</h2><p>Your Google Calendar blocks busy time inside these hours.</p></div>
1872
<div className="availability-list">
1873
{dayNames.map((name, day) => {
1874
const interval = activeSettings.availability[String(day)]?.[0];
1875
const enabled = Boolean(interval);
1876
return (
1877
<div className="availability-row" key={name}>
1878
<label className="switch-row"><input type="checkbox" checked={enabled} onChange={(e) => setDay(day, { enabled: e.target.checked })} /><span>{name}</span></label>
1879
{enabled ? (
1880
<div className="time-range">
1881
<input type="time" aria-label={`${name} start`} value={interval.start} onChange={(e) => setDay(day, { start: e.target.value })} />
1882
<span>to</span>
1883
<input type="time" aria-label={`${name} end`} value={interval.end} onChange={(e) => setDay(day, { end: e.target.value })} />
1884
</div>
1885
) : <span className="unavailable">Unavailable</span>}
1886
</div>
1887
);
1888
})}
1889
</div>
1890
<div className="availability-tools">
1891
<section className="availability-tool">
1892
<div className="card-heading"><h3>Recurring unavailable</h3><p>Private blocks such as lunch or focus time.</p></div>
1893
<div className="weekday-picker">
1894
{dayNames.map((name, day) => (
1895
<button
1896
type="button"
1897
key={name}
1898
className={recurringForm.weekdays.includes(day) ? 'active' : ''}
1899
onClick={() => setRecurringForm((current) => ({
1900
...current,
1901
weekdays: current.weekdays.includes(day)
1902
? current.weekdays.filter((value) => value !== day)
1903
: [...current.weekdays, day].sort((a, b) => a - b),
1904
}))}
1905
>
1906
{name.slice(0, 3)}
1907
</button>
1908
))}
1909
</div>
1910
<div className="calendar-settings-row">
1911
<label>From<input type="time" value={recurringForm.start} onChange={(e) => setRecurringForm({ ...recurringForm, start: e.target.value })} /></label>
1912
<label>Until<input type="time" value={recurringForm.end} onChange={(e) => setRecurringForm({ ...recurringForm, end: e.target.value })} /></label>
1913
</div>
1914
<label>Private label<input value={recurringForm.label} onChange={(e) => setRecurringForm({ ...recurringForm, label: e.target.value })} placeholder="Lunch" /></label>
1915
<button className="secondary-button" type="button" onClick={addRecurringUnavailable}>Add unavailable period</button>
1916
<div className="rule-list">
1917
{recurringUnavailable.length === 0 ? <span>No recurring unavailable periods.</span> : recurringUnavailable.map((rule) => (
1918
<div className="rule-row" key={rule.id}>
1919
<strong>{rule.weekdays.map((day) => dayNames[day].slice(0, 3)).join(', ')}</strong>
1920
<span>{rule.start}-{rule.end}{rule.label ? ` · ${rule.label}` : ''}</span>
1921
<button type="button" onClick={() => deleteAvailabilityRule('recurring', rule.id)}>Remove</button>
1922
</div>
1923
))}
1924
</div>
1925
</section>
1927
<section className="availability-tool">
1928
<div className="card-heading"><h3>One-off blocked time</h3><p>Private exceptions for a single date.</p></div>
1929
<label>Date<input type="date" value={blockForm.date} onChange={(e) => setBlockForm({ ...blockForm, date: e.target.value })} /></label>
1930
<label className="switch-row"><input type="checkbox" checked={blockForm.allDay} onChange={(e) => setBlockForm({ ...blockForm, allDay: e.target.checked })} /><span>All day</span></label>
1931
{!blockForm.allDay && (
1932
<div className="calendar-settings-row">
1933
<label>From<input type="time" value={blockForm.start} onChange={(e) => setBlockForm({ ...blockForm, start: e.target.value })} /></label>
1934
<label>Until<input type="time" value={blockForm.end} onChange={(e) => setBlockForm({ ...blockForm, end: e.target.value })} /></label>
1935
</div>
1936
)}
1937
<label>Private label<input value={blockForm.label} onChange={(e) => setBlockForm({ ...blockForm, label: e.target.value })} placeholder="Dentist" /></label>
1938
<button className="secondary-button" type="button" onClick={addAvailabilityBlock}>Block time</button>
1939
<div className="rule-list">
1940
{availabilityBlocks.length === 0 ? <span>No one-off blocks.</span> : availabilityBlocks.map((block) => (
1941
<div className="rule-row" key={block.id}>
1942
<strong>{block.date}</strong>
1943
<span>{block.allDay ? 'All day' : `${block.start}-${block.end}`}{block.label ? ` · ${block.label}` : ''}</span>
1944
<button type="button" onClick={() => deleteAvailabilityRule('block', block.id)}>Remove</button>
1945
</div>
1946
))}
1947
</div>
1948
</section>
1949
</div>
1950
</section>
1952
<section className="settings-card">
1953
<div className="card-heading"><h2>Page</h2><p>What visitors see first.</p></div>
1954
<label>Title<input value={activeSettings.title} onChange={(e) => setSettings({ ...activeSettings, title: e.target.value })} /></label>
1955
<label>Subtitle<textarea rows={3} value={activeSettings.subtitle} onChange={(e) => setSettings({ ...activeSettings, subtitle: e.target.value })} /></label>
1956
<label>Timezone
1957
<div className="timezone-control">
1958
<select value={activeSettings.timezone} onChange={(e) => setSettings({ ...activeSettings, timezone: e.target.value })}>
1959
{!timezoneOptions.includes(activeSettings.timezone) && <option value={activeSettings.timezone}>{activeSettings.timezone}</option>}
1960
{timezoneOptions.map((zone) => <option key={zone} value={zone}>{zone}</option>)}
1961
</select>
1962
<button
1963
type="button"
1964
onClick={() => setSettings({ ...activeSettings, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || activeSettings.timezone })}
1965
>
1966
Use this browser
1967
</button>
1968
</div>
1969
</label>
1970
</section>
1972
<section className="settings-card">
1973
<div className="card-heading"><h2>Booking rules</h2><p>Enough control, no dashboard archaeology.</p></div>
1974
<div className="two-col-fields">
1975
<label>Minimum notice<input type="number" min="0" value={activeSettings.minimumNoticeMinutes} onChange={(e) => setSettings({ ...activeSettings, minimumNoticeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
1976
<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>
1977
<label>Buffer before<input type="number" min="0" value={activeSettings.bufferBeforeMinutes} onChange={(e) => setSettings({ ...activeSettings, bufferBeforeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
1978
<label>Buffer after<input type="number" min="0" value={activeSettings.bufferAfterMinutes} onChange={(e) => setSettings({ ...activeSettings, bufferAfterMinutes: Number(e.target.value) })} /><small>minutes</small></label>
1979
</div>
1980
<span className="field-label">Durations</span>
1981
<div className="checkbox-chips">
1982
{[15, 30, 45, 60, 90, 120].map((minutes) => (
1983
<label key={minutes} className={activeSettings.durations.includes(minutes) ? 'active' : ''}>
1984
<input
1985
type="checkbox"
1986
checked={activeSettings.durations.includes(minutes)}
1987
onChange={(e) => setSettings({
1988
...activeSettings,
1989
durations: e.target.checked
1990
? [...activeSettings.durations, minutes].sort((a, b) => a - b)
1991
: activeSettings.durations.filter((value) => value !== minutes),
1992
paymentFreeDurations: e.target.checked
1993
? activeSettings.paymentFreeDurations
1994
: activeSettings.paymentFreeDurations.filter((value) => value !== minutes),
1995
})}
1996
/>
1997
{minutes} min
1998
</label>
1999
))}
2000
</div>
2001
</section>
2003
<section className="settings-card">
2004
<div className="card-heading"><h2>Stripe payments</h2><p>Payment happens before the booking is confirmed or the invitation is sent.</p></div>
2005
<div className="stripe-status-row">
2006
<div className={`connection-badge ${stripeConfigured ? 'connected' : ''}`}>
2007
<span className="status-dot" />
2008
{stripeConfigured ? 'Stripe secrets configured' : 'Stripe secrets missing'}
2009
</div>
2010
{stripeConfigured && (
2011
<span className={`stripe-mode-badge ${stripeMode}`}>
2012
{stripeMode === 'test' ? 'TEST MODE' : stripeMode === 'live' ? 'LIVE MODE' : 'MODE UNKNOWN'}
2013
</span>
2014
)}
2015
</div>
2016
<div className="checkbox-chips">
2017
<label className={activeSettings.paymentEnabled ? 'active' : ''}>
2018
<input
2019
type="checkbox"
2020
checked={activeSettings.paymentEnabled}
2021
onChange={(e) => setSettings({ ...activeSettings, paymentEnabled: e.target.checked })}
2022
/>
2023
Enable Stripe payments
2024
</label>
2025
</div>
2026
<div className="two-col-fields">
2027
<label>Currency<input maxLength={3} value={activeSettings.paymentCurrency} onChange={(e) => setSettings({ ...activeSettings, paymentCurrency: e.target.value.toLowerCase() })} /><small>ISO code</small></label>
2028
<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>
2029
</div>
2030
<span className="field-label">Free durations <span className="optional">Optional</span></span>
2031
<div className="checkbox-chips">
2032
{activeSettings.durations.map((minutes) => (
2033
<label key={minutes} className={activeSettings.paymentFreeDurations.includes(minutes) ? 'active' : ''}>
2034
<input
2035
type="checkbox"
2036
checked={activeSettings.paymentFreeDurations.includes(minutes)}
2037
onChange={(e) => setSettings({
2038
...activeSettings,
2039
paymentFreeDurations: e.target.checked
2040
? [...activeSettings.paymentFreeDurations, minutes].sort((a, b) => a - b)
2041
: activeSettings.paymentFreeDurations.filter((value) => value !== minutes),
2042
})}
2043
/>
2044
{minutes} min free
2045
</label>
2046
))}
2047
</div>
2048
<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>
2049
<label>Checkout label<input value={activeSettings.paymentLabel} onChange={(e) => setSettings({ ...activeSettings, paymentLabel: e.target.value })} placeholder="Booking payment" /></label>
2050
<p className="theme-footnote">
2051
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>.
2052
</p>
2053
{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>}
2054
{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>}
2055
{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>}
2056
</section>
2058
<section className="settings-card wide-card">
2059
<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>
2060
<div className="google-status-row">
2061
<div className={`connection-badge ${session.connected ? 'connected' : ''}`}>
2062
<span className="status-dot" />
2063
{session.connected ? 'Google connected' : 'Google disconnected'}
2064
</div>
2065
<div className="connection-actions">
2066
<a href={session.connected ? '/auth/google/start?force=1' : '/auth/google/start'}>{session.connected ? 'Reconnect' : 'Connect Google'}</a>
2067
{session.connected && (
2068
<button type="button" onClick={async () => {
2069
await api('/api/admin/disconnect-google', { method: 'POST' });
2070
setSession({ ...session, connected: false });
2071
}}>Disconnect</button>
2072
)}
2073
</div>
2074
</div>
2075
<div className="calendar-settings-row">
2076
<label>Create events in<input value={activeSettings.calendarId} onChange={(e) => setSettings({ ...activeSettings, calendarId: e.target.value })} placeholder="primary" /></label>
2077
<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>
2078
</div>
2079
<label>Default meeting type
2080
<select value={activeSettings.defaultMeetingMode} onChange={(e) => setSettings({ ...activeSettings, defaultMeetingMode: e.target.value as MeetingMode })}>
2081
{activeSettings.allowMeetingModes.map((mode) => <option value={mode} key={mode}>{modeMeta[mode].label}</option>)}
2082
</select>
2083
</label>
2084
<span className="field-label">Allowed meeting types</span>
2085
<div className="mode-admin-grid">
2086
{(Object.keys(modeMeta) as MeetingMode[]).map((mode) => {
2087
const Icon = modeMeta[mode].icon;
2088
const active = activeSettings.allowMeetingModes.includes(mode);
2089
return (
2090
<label key={mode} className={active ? 'active' : ''}>
2091
<input type="checkbox" checked={active} disabled={active && activeSettings.allowMeetingModes.length === 1} onChange={(e) => setSettings({
2092
...activeSettings,
2093
allowMeetingModes: e.target.checked
2094
? [...activeSettings.allowMeetingModes, mode]
2095
: activeSettings.allowMeetingModes.filter((value) => value !== mode),
2096
defaultMeetingMode: !e.target.checked && activeSettings.defaultMeetingMode === mode
2097
? (activeSettings.allowMeetingModes.find((value) => value !== mode) ?? activeSettings.defaultMeetingMode)
2098
: activeSettings.defaultMeetingMode,
2099
})} />
2100
<Icon size={18} /><span>{modeMeta[mode].label}</span>
2101
</label>
2102
);
2103
})}
2104
</div>
2105
{activeSettings.allowMeetingModes.includes('in_person') && (
2106
<label>In-person location<input value={activeSettings.inPersonLocation} onChange={(e) => setSettings({ ...activeSettings, inPersonLocation: e.target.value })} placeholder="Office, café, address…" /></label>
2107
)}
2108
</section>
2109
</div>
2110
</>
2111
) : tab === 'activity' ? (
2112
<>
2113
<div className="admin-heading">
2114
<div><p className="eyebrow">ACTIVITY</p><h1>Your Slot at a glance.</h1></div>
2115
<button className="secondary-button activity-refresh" type="button" onClick={loadActivity} disabled={activityLoading}>
2116
{activityLoading ? <><Loader2 className="spin" size={16} /> Refreshing…</> : <><Activity size={16} /> Refresh</>}
2117
</button>
2118
</div>
2119
{error && <div className="error-banner" role="alert">{error}</div>}
2120
{activityLoading && !activity ? (
2121
<section className="activity-loading"><Loader2 className="spin" size={20} /> Loading activity…</section>
2122
) : activity ? (
2123
<>
2124
<section className="activity-metrics" aria-label="Booking activity summary">
2125
<article className="activity-metric"><span>Bookings</span><strong>{activity.stats.total_bookings}</strong><small>All confirmed bookings, including later cancellations</small></article>
2126
<article className="activity-metric"><span>Upcoming</span><strong>{activity.stats.upcoming}</strong><small>Confirmed meetings still ahead</small></article>
2127
<article className="activity-metric"><span>Completed</span><strong>{activity.stats.completed}</strong><small>Confirmed meetings whose end time has passed</small></article>
2128
<article className="activity-metric"><span>Cancelled</span><strong>{activity.stats.cancelled}</strong><small>Cancelled meetings, excluding abandoned payment holds</small></article>
2129
<article className="activity-metric"><span>Reschedules</span><strong>{activity.stats.reschedules}</strong><small>Total times confirmed meetings were moved</small></article>
2130
<article className="activity-metric"><span>Paid bookings</span><strong>{activity.stats.paid_bookings}</strong><small>Bookings with a completed Stripe payment</small></article>
2131
<article className="activity-metric"><span>Average length</span><strong>{activity.stats.average_minutes} min</strong><small>Average duration of current confirmed meetings</small></article>
2132
{activity.paymentsEnabled && (
2133
<>
2134
<article className="activity-metric money"><span>Revenue</span><strong>{activityMoneyText(activity.money, 'revenue', activeSettings.paymentCurrency)}</strong><small>Gross successful Stripe payments</small></article>
2135
<article className="activity-metric money"><span>Refunds</span><strong>{activityMoneyText(activity.money, 'refunds', activeSettings.paymentCurrency)}</strong><small>Successful or currently pending refunds</small></article>
2136
<article className="activity-metric money"><span>Net revenue</span><strong>{activityMoneyText(activity.money, 'net', activeSettings.paymentCurrency)}</strong><small>Gross revenue minus refunds</small></article>
2137
<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>
2138
</>
2139
)}
2140
</section>
2142
<section className="activity-feed-card">
2143
<div className="activity-feed-heading">
2144
<div><p className="eyebrow">RECENT ACTIVITY</p><h2>What happened.</h2></div>
2145
<span>Latest {Math.min(activity.events.length, 100)} events</span>
2146
</div>
2147
{activity.events.length === 0 ? (
2148
<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>
2149
) : (
2150
<div className="activity-feed">
2151
{activity.events.map((event) => {
2152
const copy = activityEventCopy(event, activeSettings.timezone);
2153
return (
2154
<article className={`activity-feed-row type-${event.type}`} key={event.id}>
2155
<span className="activity-feed-mark" aria-hidden="true" />
2156
<div className="activity-feed-copy">
2157
<strong>{copy.title}</strong>
2158
<span>{copy.detail}</span>
2159
</div>
2160
<time dateTime={databaseInstant(event.occurred_at)}>{formatInstant(databaseInstant(event.occurred_at), activeSettings.timezone)}</time>
2161
</article>
2162
);
2163
})}
2164
</div>
2165
)}
2166
</section>
2167
</>
2168
) : (
2169
<section className="activity-loading">Activity could not be loaded.</section>
2170
)}
2171
</>
2172
) : tab === 'attendees' ? (
2173
<>
2174
<div className="admin-heading">
2175
<div><p className="eyebrow">ATTENDEES</p><h1>Who keeps booking.</h1></div>
2176
<button className="secondary-button activity-refresh" type="button" onClick={loadAttendees} disabled={attendeesLoading}>
2177
{attendeesLoading ? <><Loader2 className="spin" size={16} /> Refreshing…</> : <><Users size={16} /> Refresh</>}
2178
</button>
2179
</div>
2180
{error && <div className="error-banner" role="alert">{error}</div>}
2181
<label className="admin-search attendee-search">
2182
<Search size={16} aria-hidden="true" />
2183
<input value={attendeeSearch} onChange={(event) => setAttendeeSearch(event.target.value)} placeholder="Search attendee name or email" aria-label="Search attendees" />
2184
</label>
2185
{attendeesLoading && !attendeeData ? (
2186
<section className="activity-loading"><Loader2 className="spin" size={20} /> Loading attendees…</section>
2187
) : attendeeData ? (
2188
<>
2189
<section className="attendee-list-card">
2190
<div className="attendee-list-head">
2191
<span>Attendee</span><span>Bookings</span><span>Completed</span><span>Cancelled</span><span>Reschedules</span><span>Last booked</span><span />
2192
</div>
2193
{shownAttendees.length === 0 ? (
2194
<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>
2195
) : shownAttendees.map((attendee) => (
2196
<article className="attendee-list-row" key={attendee.email.toLowerCase()}>
2197
<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>
2198
<strong>{attendee.total_bookings}</strong>
2199
<span>{attendee.completed}</span>
2200
<span>{attendee.cancelled}</span>
2201
<span>{attendee.reschedules}</span>
2202
<time>{attendee.last_booked_at ? formatInstant(databaseInstant(attendee.last_booked_at), activeSettings.timezone) : '—'}</time>
2203
<button type="button" className="icon-button" aria-label={`Open ${attendee.name} attendee history`} onClick={() => void openAttendee(attendee)}><Inbox size={15} /></button>
2204
</article>
2205
))}
2206
</section>
2207
{selectedAttendee && (
2208
<section className="booking-detail-card attendee-detail-card">
2209
<div className="card-heading">
2210
<div><h2>{selectedAttendee.name}</h2><p>{selectedAttendee.email}</p></div>
2211
<button type="button" className="text-button" onClick={() => setSelectedAttendee(null)}>Close</button>
2212
</div>
2213
<div className="attendee-metrics">
2214
<article><span>Bookings</span><strong>{selectedAttendee.total_bookings}</strong></article>
2215
<article><span>Upcoming</span><strong>{selectedAttendee.upcoming}</strong></article>
2216
<article><span>Completed</span><strong>{selectedAttendee.completed}</strong></article>
2217
<article><span>Cancelled</span><strong>{selectedAttendee.cancelled}</strong></article>
2218
<article><span>Reschedules</span><strong>{selectedAttendee.reschedules}</strong></article>
2219
<article><span>Booked time</span><strong>{selectedAttendee.total_minutes} min</strong></article>
2220
<article><span>Average length</span><strong>{selectedAttendee.average_minutes} min</strong></article>
2221
{attendeeData.paymentsEnabled && <article><span>Paid bookings</span><strong>{selectedAttendee.paid_bookings}</strong></article>}
2222
{attendeeData.paymentsEnabled && <article><span>Refunded</span><strong>{selectedAttendee.refunded_bookings}</strong></article>}
2223
</div>
2224
<div className="attendee-history-heading"><div><p className="eyebrow">BOOKING HISTORY</p><h3>Recent meetings</h3></div><span>{attendeeBookings.length} shown</span></div>
2225
{attendeeBookingsLoading ? (
2226
<div className="activity-loading attendee-history-loading"><Loader2 className="spin" size={18} /> Loading booking history…</div>
2227
) : attendeeBookings.length ? (
2228
<div className="attendee-booking-history">{attendeeBookings.map((booking) => <BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} onOpen={openBookingFromAttendee} />)}</div>
2229
) : (
2230
<div className="empty-list attendee-history-empty"><Inbox size={22} /><strong>No booking history</strong></div>
2231
)}
2232
</section>
2233
)}
2234
</>
2235
) : <section className="activity-loading">Attendees could not be loaded.</section>}
2236
</>
2237
) : (
2238
<>
2239
<div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Your meetings.</h1></div></div>
2240
{notice && <div className="success-banner" role="status">{notice}</div>}
2241
{error && <div className="error-banner" role="alert">{error}</div>}
2242
<div className="booking-tabs" role="tablist" aria-label="Filter bookings">
2243
<button
2244
role="tab"
2245
aria-selected={bookingFilter === 'upcoming'}
2246
className={`booking-tab ${bookingFilter === 'upcoming' ? 'active' : ''}`}
2247
onClick={() => setBookingFilter('upcoming')}
2248
>
2249
Upcoming <span className="count">{upcoming.length}</span>
2250
</button>
2251
<button
2252
role="tab"
2253
aria-selected={bookingFilter === 'past'}
2254
className={`booking-tab ${bookingFilter === 'past' ? 'active' : ''}`}
2255
onClick={() => setBookingFilter('past')}
2256
>
2257
Past <span className="count">{past.length}</span>
2258
</button>
2259
</div>
2260
<label className="admin-search booking-search">
2261
<Search size={16} aria-hidden="true" />
2262
<input
2263
value={bookingSearch}
2264
onChange={(event) => setBookingSearch(event.target.value)}
2265
placeholder="Search booking ID, name or email"
2266
aria-label="Search bookings by booking ID, name or email"
2267
autoComplete="off"
2268
/>
2269
{bookingSearchLoading && <Loader2 className="spin admin-search-spinner" size={15} aria-label="Searching" />}
2270
</label>
2271
<section className="booking-list-card">
2272
{bookingSearchLoading && bookingSearch.trim() && bookingSearchResults === null ? (
2273
<div className="empty-list"><Loader2 className="spin" size={23} /><strong>Searching bookings…</strong></div>
2274
) : shown.length === 0 ? (
2275
<div className="empty-list">
2276
<Inbox size={26} />
2277
<strong>{bookingSearch.trim() ? 'No matching bookings' : bookingFilter === 'upcoming' ? 'No upcoming bookings' : 'No past bookings'}</strong>
2278
<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>
2279
</div>
2280
) : (
2281
shown.map((booking) => (
2282
<BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} onOpen={setSelectedBooking} />
2283
))
2284
)}
2285
</section>
2286
{selectedBooking && (
2287
<section className="booking-detail-card">
2288
<div className="card-heading">
2289
<div>
2290
<h2>{selectedBooking.name}</h2>
2291
<p>{selectedBooking.email}</p>
2292
</div>
2293
<button type="button" className="text-button" onClick={() => setSelectedBooking(null)}>Close</button>
2294
</div>
2295
<dl className="booking-detail-grid">
2296
<div><dt>Status</dt><dd>{selectedBooking.status}</dd></div>
2297
{selectedBooking.public_booking_id && <div><dt>Booking ID</dt><dd>{selectedBooking.public_booking_id}</dd></div>}
2298
<div><dt>Date</dt><dd>{formatDateShort(selectedBooking.start_time, activeSettings.timezone)}</dd></div>
2299
<div><dt>Start</dt><dd>{formatClock(selectedBooking.start_time, activeSettings.timezone)}</dd></div>
2300
<div><dt>End</dt><dd>{formatClock(selectedBooking.end_time, activeSettings.timezone)}</dd></div>
2301
<div><dt>Duration</dt><dd>{selectedBooking.duration_minutes} min</dd></div>
2302
<div><dt>Timezone</dt><dd>{activeSettings.timezone}</dd></div>
2303
<div><dt>Meeting</dt><dd>{modeMeta[selectedBooking.meeting_mode].label}</dd></div>
2304
<div><dt>Calendar</dt><dd>{selectedBooking.calendar_provider ?? 'google'}</dd></div>
2305
<div><dt>Sync</dt><dd>{selectedBooking.calendar_sync_status ?? 'synced'}</dd></div>
2306
<div><dt>Payment</dt><dd>{selectedBooking.payment_status ?? 'not_requested'}</dd></div>
2307
{selectedBooking.payment_status === 'paid' && <div><dt>Refund</dt><dd>{selectedBooking.refund_status ?? 'not_requested'}</dd></div>}
2308
{selectedBooking.payment_amount != null && selectedBooking.payment_currency && (
2309
<div><dt>Amount</dt><dd>{formatMoneyMinor(selectedBooking.payment_amount, selectedBooking.payment_currency)}</dd></div>
2310
)}
2311
{selectedBooking.paid_at && <div><dt>Paid</dt><dd>{formatInstant(`${selectedBooking.paid_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
2312
{selectedBooking.created_at && <div><dt>Created</dt><dd>{formatInstant(`${selectedBooking.created_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
2313
{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>}
2314
{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>}
2315
{selectedBooking.refunded_at && <div><dt>Refunded</dt><dd>{formatInstant(`${selectedBooking.refunded_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
2316
{selectedBooking.cancelled_at && <div><dt>Cancelled</dt><dd>{formatInstant(`${selectedBooking.cancelled_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
2317
</dl>
2318
{selectedBooking.calendar_sync_status === 'failed' && selectedBooking.calendar_sync_error && (
2319
<div className="error-banner" role="alert">{selectedBooking.calendar_sync_error}</div>
2320
)}
2321
<div className="booking-detail-actions">
2322
{selectedBooking.status === 'confirmed' && selectedBooking.meet_url && <a className="secondary-button" href={selectedBooking.meet_url} target="_blank" rel="noreferrer">Open meeting link</a>}
2323
<button
2324
className="secondary-button"
2325
type="button"
2326
disabled={selectedBooking.status !== 'confirmed' || Date.parse(selectedBooking.end_time) < Date.now()}
2327
onClick={() => openScheduleDialog(selectedBooking, 'reschedule')}
2328
>Reschedule</button>
2329
<button
2330
className="secondary-button"
2331
type="button"
2332
disabled={selectedBooking.status !== 'confirmed' || Date.parse(selectedBooking.end_time) < Date.now()}
2333
onClick={() => openScheduleDialog(selectedBooking, 'suggest')}
2334
>Suggest new time</button>
2335
<button
2336
className="danger-button"
2337
type="button"
2338
disabled={!(selectedBooking.status === 'pending' || selectedBooking.status === 'confirmed') || Date.parse(selectedBooking.end_time) < Date.now()}
2339
onClick={() => setCancelTarget(selectedBooking)}
2340
>
2341
{selectedBooking.status === 'pending' ? 'Release payment hold' : 'Cancel meeting'}
2342
</button>
2343
</div>
2344
</section>
2345
)}
2346
</>
2347
)}
2348
</section>
2349
</div>
2350
{scheduleTarget && (
2351
<div className="modal-backdrop" role="presentation">
2352
<section className="confirm-dialog schedule-dialog" role="dialog" aria-modal="true" aria-labelledby="schedule-action-title">
2353
<p className="eyebrow">{scheduleAction === 'reschedule' ? 'RESCHEDULE' : 'SUGGEST NEW TIME'}</p>
2354
<h2 id="schedule-action-title">{scheduleAction === 'reschedule' ? 'Move this meeting?' : 'Propose a different time?'}</h2>
2355
<div className="cancel-summary">
2356
<strong>{scheduleTarget.name}</strong>
2357
<span>Current: {formatDateShort(scheduleTarget.start_time, activeSettings.timezone)}</span>
2358
<span>{formatClock(scheduleTarget.start_time, activeSettings.timezone)}-{formatClock(scheduleTarget.end_time, activeSettings.timezone)} · {scheduleTarget.duration_minutes} min</span>
2359
</div>
2360
<p className="muted">{scheduleAction === 'reschedule'
2361
? 'Choose an available start time. The existing Google Calendar event is moved and the attendee receives an updated invitation.'
2362
: 'The existing meeting stays exactly where it is. Slot sends a Google Calendar update containing a secure acceptance link for the proposed time.'}</p>
2364
{!suggestionUrl ? (
2365
<>
2366
<label className="schedule-date-label">Date
2367
<input
2368
type="date"
2369
min={todayInZone(activeSettings.timezone)}
2370
value={scheduleDate}
2371
onChange={(event) => setScheduleDate(event.target.value)}
2372
/>
2373
</label>
2374
<div className="schedule-slot-picker" aria-label="Available start times">
2375
{loadingScheduleSlots ? (
2376
<span className="schedule-loading"><Loader2 className="spin" size={17} /> Checking availability…</span>
2377
) : scheduleSlots.length ? (
2378
scheduleSlots.map((slot) => (
2379
<button
2380
type="button"
2381
key={slot.start}
2382
className={scheduleStart === slot.start ? 'active' : ''}
2383
onClick={() => setScheduleStart(slot.start)}
2384
>
2385
{slot.time}
2386
</button>
2387
))
2388
) : (
2389
<span className="schedule-loading">No {scheduleTarget.duration_minutes}-minute starts are available on this date.</span>
2390
)}
2391
</div>
2392
</>
2393
) : (
2394
<div className="suggestion-link-box">
2395
<strong>Suggestion sent</strong>
2396
<span>Google Calendar notified the attendee. You can also send this link directly:</span>
2397
<div className="suggestion-link-row">
2398
<input readOnly value={suggestionUrl} aria-label="Reschedule suggestion link" />
2399
<button type="button" className="secondary-button" onClick={() => copyScheduleLink(suggestionUrl)}>
2400
{scheduleLinkCopied ? <><Check size={15} /> Copied</> : <><Copy size={15} /> Copy</>}
2401
</button>
2402
</div>
2403
</div>
2404
)}
2406
<div className="dialog-actions">
2407
<button
2408
className="secondary-button"
2409
type="button"
2410
onClick={() => { setScheduleTarget(null); setSuggestionUrl(''); }}
2411
disabled={savingScheduleAction}
2412
>{suggestionUrl ? 'Done' : 'Close'}</button>
2413
{!suggestionUrl && (
2414
<button className="primary-button" type="button" onClick={submitScheduleAction} disabled={!scheduleStart || savingScheduleAction}>
2415
{savingScheduleAction
2416
? <><Loader2 className="spin" size={16} /> {scheduleAction === 'reschedule' ? 'Rescheduling…' : 'Sending…'}</>
2417
: scheduleAction === 'reschedule' ? 'Reschedule meeting' : 'Send suggestion'}
2418
</button>
2419
)}
2420
</div>
2421
</section>
2422
</div>
2423
)}
2424
{cancelTarget && (
2425
<div className="modal-backdrop" role="presentation">
2426
<section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="cancel-title">
2427
<p className="eyebrow">{cancelTarget.status === 'pending' ? 'PAYMENT HOLD' : 'CANCEL MEETING'}</p>
2428
<h2 id="cancel-title">{cancelTarget.status === 'pending' ? 'Release this payment hold?' : 'Cancel meeting?'}</h2>
2429
<div className="cancel-summary">
2430
<strong>{cancelTarget.name}</strong>
2431
<span>{formatDateShort(cancelTarget.start_time, activeSettings.timezone)}</span>
2432
<span>{formatClock(cancelTarget.start_time, activeSettings.timezone)}-{formatClock(cancelTarget.end_time, activeSettings.timezone)}</span>
2433
<span>{cancelTarget.duration_minutes} min</span>
2434
</div>
2435
<p className="muted">{cancelTarget.status === 'pending'
2436
? 'This expires the open Stripe Checkout, removes the temporary calendar hold, and makes the time bookable again.'
2437
: 'This will also cancel the Google Calendar event and send normal Calendar cancellation notifications.'}</p>
2438
{cancelTarget.status === 'pending'
2439
&& checkoutSessionMode(cancelTarget.stripe_checkout_session_id) !== 'unknown'
2440
&& stripeMode !== 'unknown'
2441
&& checkoutSessionMode(cancelTarget.stripe_checkout_session_id) !== stripeMode && (
2442
<div className="error-banner" role="alert">
2443
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.
2444
</div>
2445
)}
2446
{cancelTarget.payment_status === 'paid' && (
2447
<div className="refund-banner" role="status">
2448
<strong>Full Stripe refund included</strong>
2449
<span>{cancelTarget.payment_amount != null && cancelTarget.payment_currency
2450
? `${formatMoneyMinor(cancelTarget.payment_amount, cancelTarget.payment_currency)} will be refunded to the original payment method before Slot finalizes the cancellation.`
2451
: 'Slot will refund the Stripe payment to the original payment method before finalizing the cancellation.'}</span>
2452
</div>
2453
)}
2454
<div className="dialog-actions">
2455
<button className="secondary-button" type="button" onClick={() => setCancelTarget(null)} disabled={cancelling}>{cancelTarget.status === 'pending' ? 'Keep hold' : 'Keep meeting'}</button>
2456
<button className="danger-button" type="button" onClick={confirmCancelBooking} disabled={cancelling}>
2457
{cancelling
2458
? <><Loader2 className="spin" size={16} /> {cancelTarget.status === 'pending' ? 'Releasing...' : 'Cancelling...'}</>
2459
: cancelTarget.status === 'pending' ? 'Release hold' : cancelTarget.payment_status === 'paid' ? 'Cancel & refund' : 'Cancel meeting'}
2460
</button>
2461
</div>
2462
</section>
2463
</div>
2464
)}
2465
</main>
2466
);
2467
}
2469
export default function App() {
2470
if (window.location.pathname.startsWith('/admin')) return <Admin />;
2471
if (window.location.pathname.startsWith('/reschedule/')) return <RescheduleSuggestionPage />;
2472
return <PublicBooking />;
2473
}