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
Commit
Polish booking flow and admin list
b0cb619
src/App.tsx | 457 ++++++++++++++++++++++++++++++++++++++++-------------
src/styles.css | 485 +++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 729 insertions(+), 213 deletions(-)
Diff
diff --git a/src/App.tsx b/src/App.tsx
index 7688cf5..437c6b5 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,11 +2,14 @@ import {
ArrowLeft,
ArrowRight,
CalendarDays,
+ CalendarPlus,
Check,
ChevronLeft,
ChevronRight,
Clock3,
+ Copy,
ExternalLink,
+ Inbox,
Loader2,
LogOut,
MapPin,
@@ -16,7 +19,7 @@ import {
Sparkles,
Video,
} from 'lucide-react';
-import { FormEvent, useEffect, useMemo, useState } from 'react';
+import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
type MeetingMode = 'google_meet' | 'phone' | 'in_person' | 'decide_later';
@@ -34,6 +37,15 @@ type PublicConfig = {
type Slot = { start: string; time: string; durations: number[] };
+type Confirmation = {
+ start: string;
+ end: string;
+ duration: number;
+ timezone: string;
+ meetingMode: MeetingMode;
+ meetUrl?: string | null;
+};
+
type AppSettings = {
title: string;
subtitle: string;
@@ -156,6 +168,15 @@ function prettyDate(key: string, timezone: string): string {
}).format(new Date(`${key}T12:00:00Z`));
}
+function dayAriaLabel(date: Date): string {
+ return new Intl.DateTimeFormat('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ }).format(date);
+}
+
function formatInstant(iso: string, timezone: string): string {
return new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
@@ -168,6 +189,24 @@ function formatInstant(iso: string, timezone: string): string {
}).format(new Date(iso));
}
+function formatDateShort(iso: string, timezone: string): string {
+ return new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(iso));
+}
+
+function formatClock(iso: string, timezone: string): string {
+ return new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ }).format(new Date(iso));
+}
+
function monthCells(month: Date): Array<{ date: Date; current: boolean }> {
const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
const mondayIndex = (first.getDay() + 6) % 7;
@@ -178,13 +217,29 @@ function monthCells(month: Date): Array<{ date: Date; current: boolean }> {
});
}
-function Logo() {
- return (
- <a className="brand" href="/" aria-label="Slot home">
- <span className="brand-mark">S</span>
- <span>Slot</span>
- </a>
- );
+function googleCalendarUrl(title: string, conf: Confirmation): string {
+ const stamp = (iso: string) => iso.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ const params = new URLSearchParams({
+ action: 'TEMPLATE',
+ text: title,
+ dates: `${stamp(conf.start)}/${stamp(conf.end)}`,
+ });
+ if (conf.meetUrl) params.set('details', `Google Meet: ${conf.meetUrl}`);
+ return `https://calendar.google.com/calendar/render?${params.toString()}`;
+}
+
+function useCopy(): [boolean, (value: string) => void] {
+ const [copied, setCopied] = useState(false);
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
+ useEffect(() => () => clearTimeout(timer.current), []);
+ function copy(value: string) {
+ navigator.clipboard?.writeText(value).then(() => {
+ setCopied(true);
+ clearTimeout(timer.current);
+ timer.current = setTimeout(() => setCopied(false), 1800);
+ });
+ }
+ return [copied, copy];
}
function PublicBooking() {
@@ -198,8 +253,9 @@ function PublicBooking() {
const [loadingSlots, setLoadingSlots] = useState(false);
const [booking, setBooking] = useState(false);
const [error, setError] = useState('');
+ const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string }>({});
const [availabilityNotice, setAvailabilityNotice] = useState('');
- const [confirmation, setConfirmation] = useState<{ start: string; duration: number; meetUrl?: string | null } | null>(null);
+ const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
useEffect(() => {
api<PublicConfig>('/api/config')
@@ -215,6 +271,9 @@ function PublicBooking() {
const today = config ? todayInZone(config.timezone) : dateKey(new Date());
const maxDate = config ? dateKey(addDays(parseDateKey(today), config.bookingWindowDays)) : today;
+ const isSelectable = (key: string, weekday: number, current: boolean) =>
+ Boolean(config) && current && key >= today && key <= maxDate && config!.availableWeekdays.includes(weekday);
+
async function chooseDate(key: string) {
if (!config || key < today || key > maxDate) return;
setSelectedDate(key);
@@ -242,23 +301,31 @@ function PublicBooking() {
event.preventDefault();
if (!config || !selectedSlot || !duration) return;
const form = new FormData(event.currentTarget);
+ const name = String(form.get('name') ?? '').trim();
+ const email = String(form.get('email') ?? '').trim();
+ const nextErrors: { name?: string; email?: string } = {};
+ if (!name) nextErrors.name = 'Please add your name.';
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) nextErrors.email = 'Enter a valid email address.';
+ setFieldErrors(nextErrors);
+ if (Object.keys(nextErrors).length) return;
+
setBooking(true);
setError('');
try {
- const data = await api<{ booking: { start: string; duration: number; meetUrl?: string | null } }>('/api/book', {
+ const data = await api<{ booking: Confirmation }>('/api/book', {
method: 'POST',
body: JSON.stringify({
start: selectedSlot.start,
duration,
meetingMode,
- name: form.get('name'),
- email: form.get('email'),
+ name,
+ email,
phone: form.get('phone'),
message: form.get('message'),
website: form.get('website'),
}),
});
- setConfirmation(data.booking);
+ setConfirmation({ ...data.booking, meetingMode });
} catch (err) {
const message = err instanceof Error ? err.message : 'Booking failed';
setError(message);
@@ -275,7 +342,6 @@ function PublicBooking() {
}
const activeConfig = config;
- const chosenDurations = selectedSlot?.durations ?? [];
const bookingStep = confirmation
? 'confirmed'
: !selectedSlot
@@ -299,10 +365,24 @@ function PublicBooking() {
setSelectedSlot(null);
setDuration(null);
setConfirmation(null);
+ setFieldErrors({});
setError('');
}
function renderTimesView() {
+ const groups: Array<{ label: string; items: Slot[] }> = [
+ { label: 'Morning', items: [] },
+ { label: 'Afternoon', items: [] },
+ { label: 'Evening', items: [] },
+ ];
+ for (const slot of slots) {
+ const hour = Number(slot.time.slice(0, 2));
+ const bucket = hour < 12 ? 0 : hour < 17 ? 1 : 2;
+ groups[bucket].items.push(slot);
+ }
+ const nonEmpty = groups.filter((group) => group.items.length);
+ const showLabels = slots.length > 6 && nonEmpty.length > 1;
+
return (
<div className="panel-view" key="times">
<div className="selection-heading">
@@ -311,25 +391,39 @@ function PublicBooking() {
</div>
{loadingSlots ? (
- <div className="loading-row"><Loader2 className="spin" /> Checking Google Calendar...</div>
+ <>
+ <div className="loading-row"><Loader2 className="spin" /> Checking availability…</div>
+ <div className="slot-skeleton" aria-hidden="true">
+ {Array.from({ length: 9 }, (_, i) => <span key={i} />)}
+ </div>
+ </>
) : availabilityNotice ? (
- <div className="no-slots">{availabilityNotice}</div>
+ <div className="no-slots" role="status">{availabilityNotice}</div>
) : slots.length === 0 ? (
- <div className="no-slots">Nothing open on this day.</div>
+ <div className="no-slots" role="status">Nothing open on this day. Try another date.</div>
) : (
- <div className="time-grid time-list">
- {slots.map((slot) => (
- <button
- key={slot.start}
- className={`time-button ${selectedSlot?.start === slot.start ? 'active' : ''}`}
- onClick={() => {
- setSelectedSlot(slot);
- setDuration(null);
- setConfirmation(null);
- }}
- >
- {slot.time}
- </button>
+ <div className="time-scroll">
+ {nonEmpty.map((group) => (
+ <div className="time-group" key={group.label}>
+ {showLabels && <span className="time-group-label">{group.label}</span>}
+ <div className="time-grid">
+ {group.items.map((slot) => (
+ <button
+ key={slot.start}
+ type="button"
+ className={`time-button ${selectedSlot?.start === slot.start ? 'active' : ''}`}
+ aria-pressed={selectedSlot?.start === slot.start}
+ onClick={() => {
+ setSelectedSlot(slot);
+ setDuration(null);
+ setConfirmation(null);
+ }}
+ >
+ {slot.time}
+ </button>
+ ))}
+ </div>
+ </div>
))}
</div>
)}
@@ -339,6 +433,8 @@ function PublicBooking() {
function renderDurationView() {
if (!selectedSlot) return null;
+ const available = new Set(selectedSlot.durations);
+ const hasDisabled = activeConfig.durations.some((minutes) => !available.has(minutes));
return (
<div className="panel-view" key="duration">
<button className="panel-back" type="button" onClick={changeTime}>
@@ -351,17 +447,27 @@ function PublicBooking() {
<div className="duration-picker-view">
<p className="step-label">03 · HOW LONG?</p>
<div className="duration-grid">
- {chosenDurations.map((minutes) => (
- <button
- key={minutes}
- className={`duration-button ${duration === minutes ? 'active' : ''}`}
- onClick={() => setDuration(minutes)}
- >
- <strong>{minutes}</strong>
- <span>min</span>
- </button>
- ))}
+ {activeConfig.durations.map((minutes) => {
+ const enabled = available.has(minutes);
+ return (
+ <button
+ key={minutes}
+ type="button"
+ className={`duration-button ${duration === minutes ? 'active' : ''}`}
+ disabled={!enabled}
+ aria-pressed={duration === minutes}
+ title={enabled ? undefined : 'Runs into a later booking'}
+ onClick={() => setDuration(minutes)}
+ >
+ <strong>{minutes}</strong>
+ <span>min</span>
+ </button>
+ );
+ })}
</div>
+ {hasDisabled && (
+ <p className="duration-hint">Greyed-out lengths would overlap a later booking.</p>
+ )}
</div>
</div>
);
@@ -378,23 +484,48 @@ function PublicBooking() {
<span>{prettyDate(selectedDate, activeConfig.timezone)}</span>
<strong>{selectedSlot.time} · {duration} min</strong>
</div>
- <form className="details-form compact-details" onSubmit={submitBooking}>
+ <form className="details-form compact-details" onSubmit={submitBooking} noValidate>
<p className="step-label">04 · YOUR DETAILS</p>
<div className="form-row">
- <label>Name<input name="name" required autoComplete="name" placeholder="Your name" /></label>
- <label>Email<input name="email" type="email" required autoComplete="email" placeholder="[email protected]" /></label>
+ <label>Name
+ <input
+ name="name"
+ required
+ autoComplete="name"
+ placeholder="Your name"
+ aria-invalid={fieldErrors.name ? 'true' : undefined}
+ aria-describedby={fieldErrors.name ? 'name-error' : undefined}
+ onInput={() => fieldErrors.name && setFieldErrors((e) => ({ ...e, name: undefined }))}
+ />
+ {fieldErrors.name && <span className="field-error" id="name-error">{fieldErrors.name}</span>}
+ </label>
+ <label>Email
+ <input
+ name="email"
+ type="email"
+ required
+ autoComplete="email"
+ placeholder="[email protected]"
+ aria-invalid={fieldErrors.email ? 'true' : undefined}
+ aria-describedby={fieldErrors.email ? 'email-error' : undefined}
+ onInput={() => fieldErrors.email && setFieldErrors((e) => ({ ...e, email: undefined }))}
+ />
+ {fieldErrors.email && <span className="field-error" id="email-error">{fieldErrors.email}</span>}
+ </label>
</div>
{activeConfig.allowMeetingModes.length > 1 && (
<div className="mode-picker">
<span className="field-label">Meeting</span>
- <div className="mode-options">
+ <div className="mode-options" role="radiogroup" aria-label="Meeting method">
{activeConfig.allowMeetingModes.map((mode) => {
const Icon = modeMeta[mode].icon;
return (
<button
key={mode}
type="button"
+ role="radio"
+ aria-checked={meetingMode === mode}
className={`mode-option ${meetingMode === mode ? 'active' : ''}`}
onClick={() => setMeetingMode(mode)}
>
@@ -415,7 +546,7 @@ function PublicBooking() {
</label>
<label className="honeypot" aria-hidden="true">Website<input name="website" tabIndex={-1} autoComplete="off" /></label>
- {error && <div className="error-banner">{error}</div>}
+ {error && <div className="error-banner" role="alert">{error}</div>}
<button className="primary-button" type="submit" disabled={booking}>
{booking ? <><Loader2 className="spin" size={18} /> Booking...</> : <>Book this slot <ArrowRight size={18} /></>}
</button>
@@ -426,21 +557,7 @@ function PublicBooking() {
function renderConfirmationView() {
if (!confirmation) return null;
- return (
- <div className="panel-view confirmation-panel" key="confirmed">
- <span className="success-icon"><Check /></span>
- <p className="eyebrow">YOU'RE BOOKED</p>
- <h2>See you then.</h2>
- <p className="confirmation-time">{formatInstant(confirmation.start, activeConfig.timezone)} · {confirmation.duration} min</p>
- <p className="muted">A calendar invitation has been sent to your email.</p>
- {confirmation.meetUrl && (
- <a className="primary-button inline-button" href={confirmation.meetUrl} target="_blank" rel="noreferrer">
- Open Google Meet <ExternalLink size={16} />
- </a>
- )}
- <button className="text-button" type="button" onClick={bookAnotherTime}>Book another time</button>
- </div>
- );
+ return <ConfirmationView title={activeConfig.title} confirmation={confirmation} onBookAnother={bookAnotherTime} />;
}
function renderRightPanel() {
@@ -490,32 +607,35 @@ function PublicBooking() {
</div>
</aside>
- <section className="calendar-panel">
+ <section className="calendar-panel" aria-label="Choose a date">
<div className="calendar-toolbar">
<div>
<p className="step-label">01 · PICK A DAY</p>
- <h2>{month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</h2>
+ <h2 aria-live="polite">{month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</h2>
</div>
<div className="month-buttons">
- <button aria-label="Previous month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><ChevronLeft /></button>
- <button aria-label="Next month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><ChevronRight /></button>
+ <button type="button" aria-label="Previous month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><ChevronLeft /></button>
+ <button type="button" aria-label="Next month" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><ChevronRight /></button>
</div>
</div>
- <div className="weekday-grid">
+ <div className="weekday-grid" aria-hidden="true">
{shortDayNames.map((name) => <span key={name}>{name}</span>)}
</div>
<div className="calendar-grid">
{cells.map(({ date, current }) => {
const key = dateKey(date);
- const disabled = !current || key < today || key > maxDate || !activeConfig.availableWeekdays.includes(date.getDay());
+ const selectable = isSelectable(key, date.getDay(), current);
const selected = key === selectedDate;
const isToday = key === today;
return (
<button
key={key}
+ type="button"
className={`day ${current ? '' : 'outside'} ${selected ? 'selected' : ''} ${isToday ? 'today' : ''}`}
- disabled={disabled}
+ disabled={!selectable}
+ aria-pressed={selected}
+ aria-label={dayAriaLabel(date)}
onClick={() => chooseDate(key)}
>
<span>{date.getDate()}</span>
@@ -526,10 +646,10 @@ function PublicBooking() {
</div>
</section>
- <aside className="selection-panel">{renderRightPanel()}</aside>
+ <aside className="selection-panel" aria-live="polite">{renderRightPanel()}</aside>
</section>
- {error && !(selectedSlot && duration) && <div className="floating-error">{error}</div>}
+ {error && !(selectedSlot && duration) && <div className="floating-error" role="alert">{error}</div>}
<footer>
<a className="powered-by" href="https://www.nobgit.com/user/imalexnord/slot" target="_blank" rel="noreferrer">Powered by Slot</a>
</footer>
@@ -537,11 +657,97 @@ function PublicBooking() {
);
}
+function ConfirmationView({ title, confirmation, onBookAnother }: { title: string; confirmation: Confirmation; onBookAnother: () => void }) {
+ const [copied, copy] = useCopy();
+ const tz = confirmation.timezone;
+ return (
+ <div className="panel-view confirmation-panel panel-view-scroll" key="confirmed">
+ <span className="success-icon"><Check /></span>
+ <p className="eyebrow">YOU'RE BOOKED</p>
+ <h2>See you then.</h2>
+
+ <dl className="confirmation-details">
+ <div className="confirm-row"><dt>Date</dt><dd>{formatDateShort(confirmation.start, tz)}</dd></div>
+ <div className="confirm-row"><dt>Time</dt><dd>{formatClock(confirmation.start, tz)} – {formatClock(confirmation.end, tz)}</dd></div>
+ <div className="confirm-row"><dt>Length</dt><dd>{confirmation.duration} min</dd></div>
+ <div className="confirm-row"><dt>Timezone</dt><dd>{tz}</dd></div>
+ <div className="confirm-row"><dt>Meeting</dt><dd>{modeMeta[confirmation.meetingMode].label}</dd></div>
+ </dl>
+
+ <p className="muted">A calendar invitation has been sent to your email.</p>
+
+ <div className="confirm-actions">
+ <a className="primary-button" href={googleCalendarUrl(title, confirmation)} target="_blank" rel="noreferrer">
+ <CalendarPlus size={16} /> Add to calendar
+ </a>
+ {confirmation.meetUrl && (
+ <>
+ <a className="secondary-button" href={confirmation.meetUrl} target="_blank" rel="noreferrer">
+ Open Google Meet <ExternalLink size={15} />
+ </a>
+ <button
+ type="button"
+ className={`copy-button ${copied ? 'copied' : ''}`}
+ onClick={() => copy(confirmation.meetUrl!)}
+ >
+ {copied ? <><Check size={15} /> Link copied</> : <><Copy size={15} /> Copy Meet link</>}
+ </button>
+ </>
+ )}
+ <button className="text-button" type="button" onClick={onBookAnother}>Book another time</button>
+ </div>
+ </div>
+ );
+}
+
+function BookingRowItem({ booking, timezone }: { booking: BookingRow; timezone: string }) {
+ const [copied, copy] = useCopy();
+ const isPast = Date.parse(booking.end_time) < Date.now();
+ const Icon = modeMeta[booking.meeting_mode].icon;
+ return (
+ <div className={`booking-list-row ${isPast ? 'past' : ''}`}>
+ <div className="booking-avatar" aria-hidden="true">{booking.name.slice(0, 1).toUpperCase()}</div>
+ <div className="booking-person">
+ <strong>{booking.name}</strong>
+ <span>{booking.email}</span>
+ </div>
+ <div className="booking-when">
+ <strong>{formatDateShort(booking.start_time, timezone)}</strong>
+ <span>{formatClock(booking.start_time, timezone)} – {formatClock(booking.end_time, timezone)}</span>
+ </div>
+ <div className="booking-meta">
+ <span className="booking-method"><Icon size={13} /> {modeMeta[booking.meeting_mode].label}</span>
+ <span>{booking.duration_minutes} min</span>
+ </div>
+ <div className="booking-actions">
+ <span className={`status-pill ${isPast ? 'past' : 'upcoming'}`}>{isPast ? 'Past' : 'Upcoming'}</span>
+ {booking.meet_url && (
+ <>
+ <button
+ type="button"
+ className={`icon-button ${copied ? 'copied' : ''}`}
+ aria-label="Copy Meet link"
+ title="Copy Meet link"
+ onClick={() => copy(booking.meet_url!)}
+ >
+ {copied ? <Check size={15} /> : <Copy size={15} />}
+ </button>
+ <a className="icon-button" href={booking.meet_url} target="_blank" rel="noreferrer" aria-label="Open Google Meet" title="Open Google Meet">
+ <Video size={15} />
+ </a>
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
+
function Admin() {
const [session, setSession] = useState<AdminSession | null>(null);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [bookings, setBookings] = useState<BookingRow[]>([]);
const [tab, setTab] = useState<'settings' | 'bookings'>('bookings');
+ const [bookingFilter, setBookingFilter] = useState<'upcoming' | 'past'>('upcoming');
const [theme, setTheme] = useState<ThemeId>(() => {
const stored = localStorage.getItem('meet-theme') as ThemeId | null;
return stored && themes.some((item) => item.id === stored) ? stored : 'true-black';
@@ -611,7 +817,7 @@ function Admin() {
<p className="eyebrow">PRIVATE</p>
<h1>Control your availability.</h1>
<p>Google login is only for the calendar owner. People booking you never need an account.</p>
- {oauthError && <div className="error-banner">{oauthError}</div>}
+ {oauthError && <div className="error-banner" role="alert">{oauthError}</div>}
<a className="primary-button inline-button" href="/auth/google/start">Continue with Google <ArrowRight size={18} /></a>
<a className="text-button" href="/">Back to booking page</a>
</section>
@@ -636,6 +842,12 @@ function Admin() {
});
}
+ const activeSettings = settings;
+ const now = Date.now();
+ const upcoming = bookings.filter((b) => Date.parse(b.end_time) >= now);
+ const past = bookings.filter((b) => Date.parse(b.end_time) < now);
+ const shown = bookingFilter === 'upcoming' ? upcoming : past;
+
return (
<main className="admin-page">
<header className="admin-topbar">
@@ -659,8 +871,8 @@ function Admin() {
<div><p className="eyebrow">SETTINGS</p><h1>Keep it simple.</h1></div>
<button className="primary-button compact" onClick={save} disabled={saving}>{saving ? <Loader2 className="spin" /> : <Check />} Save changes</button>
</div>
- {notice && <div className="success-banner">{notice}</div>}
- {error && <div className="error-banner">{error}</div>}
+ {notice && <div className="success-banner" role="status">{notice}</div>}
+ {error && <div className="error-banner" role="alert">{error}</div>}
<div className="settings-grid">
<section className="settings-card wide-card appearance-card">
@@ -678,6 +890,7 @@ function Admin() {
type="button"
key={item.id}
className={`theme-option ${theme === item.id ? 'active' : ''}`}
+ aria-pressed={theme === item.id}
onClick={() => setTheme(item.id)}
>
<span className={`theme-preview theme-preview-${item.id}`} aria-hidden="true">
@@ -698,16 +911,16 @@ function Admin() {
<div className="card-heading"><h2>Availability</h2><p>Your Google Calendar blocks busy time inside these hours.</p></div>
<div className="availability-list">
{dayNames.map((name, day) => {
- const interval = settings.availability[String(day)]?.[0];
+ const interval = activeSettings.availability[String(day)]?.[0];
const enabled = Boolean(interval);
return (
<div className="availability-row" key={name}>
<label className="switch-row"><input type="checkbox" checked={enabled} onChange={(e) => setDay(day, { enabled: e.target.checked })} /><span>{name}</span></label>
{enabled ? (
<div className="time-range">
- <input type="time" value={interval.start} onChange={(e) => setDay(day, { start: e.target.value })} />
+ <input type="time" aria-label={`${name} start`} value={interval.start} onChange={(e) => setDay(day, { start: e.target.value })} />
<span>to</span>
- <input type="time" value={interval.end} onChange={(e) => setDay(day, { end: e.target.value })} />
+ <input type="time" aria-label={`${name} end`} value={interval.end} onChange={(e) => setDay(day, { end: e.target.value })} />
</div>
) : <span className="unavailable">Unavailable</span>}
</div>
@@ -718,17 +931,17 @@ function Admin() {
<section className="settings-card">
<div className="card-heading"><h2>Page</h2><p>What visitors see first.</p></div>
- <label>Title<input value={settings.title} onChange={(e) => setSettings({ ...settings, title: e.target.value })} /></label>
- <label>Subtitle<textarea rows={3} value={settings.subtitle} onChange={(e) => setSettings({ ...settings, subtitle: e.target.value })} /></label>
+ <label>Title<input value={activeSettings.title} onChange={(e) => setSettings({ ...activeSettings, title: e.target.value })} /></label>
+ <label>Subtitle<textarea rows={3} value={activeSettings.subtitle} onChange={(e) => setSettings({ ...activeSettings, subtitle: e.target.value })} /></label>
<label>Timezone
<div className="timezone-control">
- <select value={settings.timezone} onChange={(e) => setSettings({ ...settings, timezone: e.target.value })}>
- {!timezoneOptions.includes(settings.timezone) && <option value={settings.timezone}>{settings.timezone}</option>}
+ <select value={activeSettings.timezone} onChange={(e) => setSettings({ ...activeSettings, timezone: e.target.value })}>
+ {!timezoneOptions.includes(activeSettings.timezone) && <option value={activeSettings.timezone}>{activeSettings.timezone}</option>}
{timezoneOptions.map((zone) => <option key={zone} value={zone}>{zone}</option>)}
</select>
<button
type="button"
- onClick={() => setSettings({ ...settings, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || settings.timezone })}
+ onClick={() => setSettings({ ...activeSettings, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || activeSettings.timezone })}
>
Use this browser
</button>
@@ -739,23 +952,23 @@ function Admin() {
<section className="settings-card">
<div className="card-heading"><h2>Booking rules</h2><p>Enough control, no dashboard archaeology.</p></div>
<div className="two-col-fields">
- <label>Minimum notice<input type="number" min="0" value={settings.minimumNoticeMinutes} onChange={(e) => setSettings({ ...settings, minimumNoticeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
- <label>Book ahead<input type="number" min="1" max="365" value={settings.bookingWindowDays} onChange={(e) => setSettings({ ...settings, bookingWindowDays: Number(e.target.value) })} /><small>days</small></label>
- <label>Buffer before<input type="number" min="0" value={settings.bufferBeforeMinutes} onChange={(e) => setSettings({ ...settings, bufferBeforeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
- <label>Buffer after<input type="number" min="0" value={settings.bufferAfterMinutes} onChange={(e) => setSettings({ ...settings, bufferAfterMinutes: Number(e.target.value) })} /><small>minutes</small></label>
+ <label>Minimum notice<input type="number" min="0" value={activeSettings.minimumNoticeMinutes} onChange={(e) => setSettings({ ...activeSettings, minimumNoticeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
+ <label>Book ahead<input type="number" min="1" max="365" value={activeSettings.bookingWindowDays} onChange={(e) => setSettings({ ...activeSettings, bookingWindowDays: Number(e.target.value) })} /><small>days</small></label>
+ <label>Buffer before<input type="number" min="0" value={activeSettings.bufferBeforeMinutes} onChange={(e) => setSettings({ ...activeSettings, bufferBeforeMinutes: Number(e.target.value) })} /><small>minutes</small></label>
+ <label>Buffer after<input type="number" min="0" value={activeSettings.bufferAfterMinutes} onChange={(e) => setSettings({ ...activeSettings, bufferAfterMinutes: Number(e.target.value) })} /><small>minutes</small></label>
</div>
<span className="field-label">Durations</span>
<div className="checkbox-chips">
{[15, 30, 45, 60, 90, 120].map((minutes) => (
- <label key={minutes} className={settings.durations.includes(minutes) ? 'active' : ''}>
+ <label key={minutes} className={activeSettings.durations.includes(minutes) ? 'active' : ''}>
<input
type="checkbox"
- checked={settings.durations.includes(minutes)}
+ checked={activeSettings.durations.includes(minutes)}
onChange={(e) => setSettings({
- ...settings,
+ ...activeSettings,
durations: e.target.checked
- ? [...settings.durations, minutes].sort((a, b) => a - b)
- : settings.durations.filter((value) => value !== minutes),
+ ? [...activeSettings.durations, minutes].sort((a, b) => a - b)
+ : activeSettings.durations.filter((value) => value !== minutes),
})}
/>
{minutes} min
@@ -782,53 +995,75 @@ function Admin() {
</div>
</div>
<div className="calendar-settings-row">
- <label>Create events in<input value={settings.calendarId} onChange={(e) => setSettings({ ...settings, calendarId: e.target.value })} placeholder="primary" /></label>
- <label>Calendars that block time<input value={settings.busyCalendarIds.join(', ')} onChange={(e) => setSettings({ ...settings, busyCalendarIds: e.target.value.split(',').map((v) => v.trim()).filter(Boolean) })} placeholder="primary" /></label>
+ <label>Create events in<input value={activeSettings.calendarId} onChange={(e) => setSettings({ ...activeSettings, calendarId: e.target.value })} placeholder="primary" /></label>
+ <label>Calendars that block time<input value={activeSettings.busyCalendarIds.join(', ')} onChange={(e) => setSettings({ ...activeSettings, busyCalendarIds: e.target.value.split(',').map((v) => v.trim()).filter(Boolean) })} placeholder="primary" /></label>
</div>
<label>Default meeting type
- <select value={settings.defaultMeetingMode} onChange={(e) => setSettings({ ...settings, defaultMeetingMode: e.target.value as MeetingMode })}>
- {settings.allowMeetingModes.map((mode) => <option value={mode} key={mode}>{modeMeta[mode].label}</option>)}
+ <select value={activeSettings.defaultMeetingMode} onChange={(e) => setSettings({ ...activeSettings, defaultMeetingMode: e.target.value as MeetingMode })}>
+ {activeSettings.allowMeetingModes.map((mode) => <option value={mode} key={mode}>{modeMeta[mode].label}</option>)}
</select>
</label>
<span className="field-label">Allowed meeting types</span>
<div className="mode-admin-grid">
{(Object.keys(modeMeta) as MeetingMode[]).map((mode) => {
const Icon = modeMeta[mode].icon;
- const active = settings.allowMeetingModes.includes(mode);
+ const active = activeSettings.allowMeetingModes.includes(mode);
return (
<label key={mode} className={active ? 'active' : ''}>
- <input type="checkbox" checked={active} disabled={active && settings.allowMeetingModes.length === 1} onChange={(e) => setSettings({
- ...settings,
+ <input type="checkbox" checked={active} disabled={active && activeSettings.allowMeetingModes.length === 1} onChange={(e) => setSettings({
+ ...activeSettings,
allowMeetingModes: e.target.checked
- ? [...settings.allowMeetingModes, mode]
- : settings.allowMeetingModes.filter((value) => value !== mode),
- defaultMeetingMode: !e.target.checked && settings.defaultMeetingMode === mode
- ? (settings.allowMeetingModes.find((value) => value !== mode) ?? settings.defaultMeetingMode)
- : settings.defaultMeetingMode,
+ ? [...activeSettings.allowMeetingModes, mode]
+ : activeSettings.allowMeetingModes.filter((value) => value !== mode),
+ defaultMeetingMode: !e.target.checked && activeSettings.defaultMeetingMode === mode
+ ? (activeSettings.allowMeetingModes.find((value) => value !== mode) ?? activeSettings.defaultMeetingMode)
+ : activeSettings.defaultMeetingMode,
})} />
<Icon size={18} /><span>{modeMeta[mode].label}</span>
</label>
);
})}
</div>
- {settings.allowMeetingModes.includes('in_person') && (
- <label>In-person location<input value={settings.inPersonLocation} onChange={(e) => setSettings({ ...settings, inPersonLocation: e.target.value })} placeholder="Office, café, address…" /></label>
+ {activeSettings.allowMeetingModes.includes('in_person') && (
+ <label>In-person location<input value={activeSettings.inPersonLocation} onChange={(e) => setSettings({ ...activeSettings, inPersonLocation: e.target.value })} placeholder="Office, café, address…" /></label>
)}
</section>
</div>
</>
) : (
<>
- <div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Recent meetings.</h1></div></div>
+ <div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Your meetings.</h1></div></div>
+ {error && <div className="error-banner" role="alert">{error}</div>}
+ <div className="booking-tabs" role="tablist" aria-label="Filter bookings">
+ <button
+ role="tab"
+ aria-selected={bookingFilter === 'upcoming'}
+ className={`booking-tab ${bookingFilter === 'upcoming' ? 'active' : ''}`}
+ onClick={() => setBookingFilter('upcoming')}
+ >
+ Upcoming <span className="count">{upcoming.length}</span>
+ </button>
+ <button
+ role="tab"
+ aria-selected={bookingFilter === 'past'}
+ className={`booking-tab ${bookingFilter === 'past' ? 'active' : ''}`}
+ onClick={() => setBookingFilter('past')}
+ >
+ Past <span className="count">{past.length}</span>
+ </button>
+ </div>
<section className="booking-list-card">
- {bookings.length === 0 ? <div className="empty-list">No bookings yet.</div> : bookings.map((booking) => (
- <div className="booking-list-row" key={booking.id}>
- <div className="booking-avatar">{booking.name.slice(0, 1).toUpperCase()}</div>
- <div className="booking-person"><strong>{booking.name}</strong><span>{booking.email}</span></div>
- <div className="booking-when"><strong>{formatInstant(booking.start_time, settings.timezone)}</strong><span>{booking.duration_minutes} min · {modeMeta[booking.meeting_mode].label}</span></div>
- {booking.meet_url ? <a href={booking.meet_url} target="_blank" rel="noreferrer"><Video size={17} /> Meet</a> : <span />}
+ {shown.length === 0 ? (
+ <div className="empty-list">
+ <Inbox size={26} />
+ <strong>{bookingFilter === 'upcoming' ? 'No upcoming bookings' : 'No past bookings'}</strong>
+ <span>{bookingFilter === 'upcoming' ? 'New bookings from your calendar page will show up here.' : 'Completed meetings will be listed here.'}</span>
</div>
- ))}
+ ) : (
+ shown.map((booking) => (
+ <BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} />
+ ))
+ )}
</section>
</>
)}
diff --git a/src/styles.css b/src/styles.css
index 2b2e73a..69a4516 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -17,6 +17,20 @@
--success: #217a4f;
--danger: #a13b3b;
--shadow: 0 24px 70px rgba(18, 19, 23, 0.08);
+
+ /* Shared design tokens (theme-independent). */
+ --radius-pill: 999px;
+ --shell-radius: 24px;
+ --radius-lg: 20px;
+ --radius-md: 14px;
+ --radius-sm: 11px;
+ --radius-xs: 9px;
+ --control-h: 42px;
+ --control-h-sm: 36px;
+ /* Selected surfaces: a slightly softened off-white/off-ink that still reads high-contrast. */
+ --selected-bg: color-mix(in srgb, var(--text) 90%, var(--muted));
+ --selected-text: var(--bg);
+ --focus-ring: 0 0 0 3px color-mix(in srgb, var(--accent) 38%, transparent);
}
@media (prefers-color-scheme: dark) {
@@ -194,23 +208,45 @@
* { box-sizing: border-box; }
html, body, #root {
width: 100%;
- height: 100%;
+ min-height: 100%;
margin: 0;
}
html { background: var(--bg); }
body {
min-width: 320px;
- overflow: hidden;
+ /* Pages scroll by default (the admin can be taller than the viewport). */
+ overflow-x: hidden;
+ overflow-y: auto;
background: var(--bg);
color: var(--text);
}
-button, input, textarea { font: inherit; }
+/* The public booking view is an intentional single-screen; lock it on desktop
+ only, so the scrollable admin and the stacked mobile layouts are unaffected. */
+@media (min-width: 851px) {
+ body:has(.booking-page) { overflow: hidden; height: 100%; }
+}
+button, input, textarea, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
::selection { background: var(--text); color: var(--bg); }
+.visually-hidden {
+ position: absolute !important;
+ width: 1px; height: 1px;
+ padding: 0; margin: -1px;
+ overflow: hidden; clip: rect(0 0 0 0);
+ white-space: nowrap; border: 0;
+}
+
+/* Consistent, understated focus ring for keyboard users everywhere. */
+:where(button, a, input, textarea, select, [tabindex]):focus-visible {
+ outline: none;
+ box-shadow: var(--focus-ring);
+ border-color: color-mix(in srgb, var(--accent) 60%, var(--border));
+}
+
.center-screen {
min-height: 100vh;
display: grid;
@@ -236,7 +272,7 @@ a { color: inherit; text-decoration: none; }
height: 29px;
display: grid;
place-items: center;
- border-radius: 9px;
+ border-radius: var(--radius-xs);
background: var(--text);
color: var(--bg);
font-size: 13px;
@@ -265,9 +301,9 @@ a { color: inherit; text-decoration: none; }
.topbar {
position: absolute;
top: 28px;
- left: 26px;
- right: 26px;
- width: calc(100% - 52px);
+ left: 30px;
+ right: 30px;
+ width: calc(100% - 60px);
margin: 0 auto;
justify-content: flex-end;
}
@@ -279,37 +315,40 @@ a { color: inherit; text-decoration: none; }
font-size: 12px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 78%, transparent);
- padding: 8px 11px;
- border-radius: 999px;
+ padding: 8px 12px;
+ border-radius: var(--radius-pill);
}
.booking-shell {
- width: min(1180px, calc(100vw - 32px));
- height: min(760px, calc(100dvh - 88px));
+ width: min(1200px, calc(100vw - 32px));
+ height: min(800px, calc(100dvh - 92px));
max-width: calc(100vw - 32px);
- max-height: calc(100dvh - 88px);
+ max-height: calc(100dvh - 92px);
margin: 0;
display: grid;
- grid-template-columns: minmax(230px, .78fr) minmax(500px, 1.55fr) minmax(270px, .88fr);
+ grid-template-columns: minmax(250px, .82fr) minmax(500px, 1.5fr) minmax(300px, .92fr);
min-height: 0;
border: 1px solid var(--border);
- border-radius: 26px;
+ border-radius: var(--shell-radius);
background: var(--surface);
overflow: hidden;
box-shadow: var(--shadow);
+ transition: grid-template-columns 340ms cubic-bezier(.4, 0, .2, 1);
}
.booking-shell > * + * { border-left: 1px solid var(--border); }
+
.intro-panel {
min-height: 0;
- padding: 48px 34px 36px;
+ padding: 52px 38px 40px;
display: flex;
flex-direction: column;
justify-content: space-between;
+ gap: 28px;
background: linear-gradient(160deg, var(--surface) 0%, var(--surface-soft) 130%);
}
.eyebrow, .step-label {
- margin: 0 0 11px;
+ margin: 0 0 12px;
color: var(--faint);
font-weight: 750;
font-size: 10px;
@@ -317,14 +356,14 @@ a { color: inherit; text-decoration: none; }
}
.intro-panel h1, .confirmation-card h1, .admin-login-card h1, .admin-heading h1 {
margin: 0;
- font-size: clamp(32px, 3vw, 52px);
+ font-size: clamp(34px, 3.1vw, 56px);
line-height: .98;
letter-spacing: -0.055em;
font-weight: 680;
}
.intro-copy {
- max-width: 390px;
- margin: 20px 0 0;
+ max-width: 400px;
+ margin: 22px 0 0;
color: var(--muted);
line-height: 1.6;
font-size: 15px;
@@ -339,37 +378,38 @@ a { color: inherit; text-decoration: none; }
font-size: 12px;
line-height: 1.5;
}
-.intro-note svg { margin-top: 2px; color: var(--text); }
+.intro-note svg { margin-top: 2px; color: var(--text); flex-shrink: 0; }
.intro-note div { display: grid; gap: 4px; }
.intro-note strong { color: var(--text); font-size: 12px; }
.calendar-panel {
min-height: 0;
- padding: 38px 40px 42px;
+ padding: 42px 44px 46px;
display: flex;
flex-direction: column;
}
-.calendar-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 22px; margin-bottom: 34px; }
+.calendar-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 22px; margin-bottom: 30px; }
.calendar-toolbar h2, .selection-heading h2, .drawer-summary h2, .settings-card h2 {
margin: 0;
- font-size: 22px;
+ font-size: 23px;
letter-spacing: -0.035em;
}
-.month-buttons { display: flex; gap: 7px; }
+.month-buttons { display: flex; gap: 8px; }
.month-buttons button, .admin-user button {
- width: 36px;
- height: 36px;
- border-radius: 11px;
+ width: var(--control-h);
+ height: var(--control-h);
+ border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
display: inline-grid;
place-items: center;
cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease;
}
-.month-buttons button:hover { background: var(--surface-soft); }
-.month-buttons svg { width: 17px; }
+.month-buttons button:hover { background: var(--surface-soft); border-color: color-mix(in srgb, var(--text) 25%, var(--border)); }
+.month-buttons svg { width: 18px; }
.weekday-grid, .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
-.weekday-grid { margin-bottom: 9px; }
+.weekday-grid { margin-bottom: 10px; }
.weekday-grid span {
text-align: center;
color: var(--faint);
@@ -382,13 +422,13 @@ a { color: inherit; text-decoration: none; }
flex: 1;
min-height: 0;
grid-template-rows: repeat(6, minmax(0, 1fr));
- gap: 7px;
+ gap: 8px;
}
.day {
min-height: 0;
border: 1px solid transparent;
background: transparent;
- border-radius: 14px;
+ border-radius: var(--radius-md);
cursor: pointer;
display: flex;
flex-direction: column;
@@ -396,34 +436,46 @@ a { color: inherit; text-decoration: none; }
justify-content: center;
gap: 3px;
position: relative;
+ color: inherit;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
-.day span { font-size: 13px; font-weight: 620; }
-.day small { font-size: 8px; color: var(--faint); }
+.day span { font-size: 14px; font-weight: 620; }
+.day small {
+ font-size: 8px;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+ color: var(--faint);
+ font-weight: 700;
+}
.day:hover:not(:disabled) { background: var(--surface-soft); border-color: var(--border); transform: translateY(-1px); }
-.day.selected { background: var(--text); color: var(--bg); border-color: var(--text); }
-.day.selected small { color: color-mix(in srgb, var(--bg) 70%, transparent); }
+.day.selected { background: var(--selected-bg); color: var(--selected-text); border-color: var(--selected-bg); }
+.day.selected small { color: color-mix(in srgb, var(--selected-text) 70%, transparent); }
+/* Today: a subtle accent label rather than a competing outline. */
.day.today:not(.selected) { border-color: var(--border); }
-.day.outside { opacity: .2; }
+.day.today:not(.selected) small { color: var(--accent); }
+.day.outside { opacity: .18; }
.day:disabled { cursor: default; opacity: .25; }
-.day.outside:disabled { opacity: .12; }
+.day.outside:disabled { opacity: .1; }
.selection-panel {
- padding: 38px 34px;
+ padding: 42px 36px;
background: color-mix(in srgb, var(--surface-soft) 52%, var(--surface));
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
+ transition: opacity 240ms ease, padding 340ms cubic-bezier(.4, 0, .2, 1), border-left-width 340ms cubic-bezier(.4, 0, .2, 1);
}
-.selection-heading { margin-bottom: 25px; }
-.selection-heading h2 { font-size: 19px; }
+.selection-heading { margin-bottom: 24px; }
+.selection-heading h2 { font-size: 20px; }
.panel-view {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
+ animation: panel-in 220ms cubic-bezier(.4, 0, .2, 1);
}
+@keyframes panel-in { from { opacity: 0; transform: translateX(8px); } }
.panel-view-scroll {
overflow-y: auto;
padding-right: 4px;
@@ -436,20 +488,23 @@ a { color: inherit; text-decoration: none; }
display: inline-flex;
align-items: center;
gap: 7px;
- padding: 0 0 16px;
+ padding: 4px 6px 4px 4px;
+ margin: 0 0 14px -4px;
+ border-radius: var(--radius-xs);
cursor: pointer;
font-size: 12px;
font-weight: 720;
+ transition: color 120ms ease, background 120ms ease;
}
-.panel-back:hover { color: var(--text); }
+.panel-back:hover { color: var(--text); background: var(--surface-soft); }
.selected-summary {
border: 1px solid var(--border);
- border-radius: 14px;
+ border-radius: var(--radius-md);
background: var(--surface);
- padding: 14px;
+ padding: 14px 15px;
display: grid;
gap: 5px;
- margin-bottom: 28px;
+ margin-bottom: 26px;
}
.selected-summary span {
color: var(--muted);
@@ -481,42 +536,84 @@ a { color: inherit; text-decoration: none; }
.subtle-empty svg { opacity: .45; }
.loading-row, .no-slots { color: var(--muted); font-size: 13px; display: flex; gap: 9px; align-items: center; padding: 18px 0; }
.loading-row svg { width: 16px; }
-.time-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
-.time-list {
+
+/* Availability skeletons keep the panel from collapsing while loading. */
+.slot-skeleton {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+.slot-skeleton span {
+ height: 40px;
+ border-radius: var(--radius-sm);
+ background: linear-gradient(100deg, var(--surface-soft) 30%, color-mix(in srgb, var(--surface-strong) 70%, var(--surface-soft)) 50%, var(--surface-soft) 70%);
+ background-size: 220% 100%;
+ animation: skeleton 1.2s ease-in-out infinite;
+}
+@keyframes skeleton { to { background-position: -220% 0; } }
+
+.time-scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
- align-content: start;
padding-right: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
}
+.time-group { display: grid; gap: 9px; }
+.time-group-label {
+ color: var(--faint);
+ font-size: 9px;
+ font-weight: 760;
+ letter-spacing: .13em;
+ text-transform: uppercase;
+}
+.time-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; align-content: start; }
.time-button {
border: 1px solid var(--border);
background: var(--surface);
- border-radius: 11px;
+ border-radius: var(--radius-sm);
padding: 11px 8px;
cursor: pointer;
+ color: inherit;
font-weight: 650;
font-size: 12px;
- transition: 120ms ease;
+ font-variant-numeric: tabular-nums;
+ transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
}
-.time-button:hover { border-color: color-mix(in srgb, var(--text) 45%, var(--border)); }
-.time-button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
-.duration-section { border-top: 1px solid var(--border); margin-top: 28px; padding-top: 25px; }
+.time-button:hover { border-color: color-mix(in srgb, var(--text) 45%, var(--border)); background: var(--surface-soft); }
+.time-button.active { background: var(--selected-bg); color: var(--selected-text); border-color: var(--selected-bg); }
.duration-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.duration-button {
border: 1px solid var(--border);
- border-radius: 12px;
- padding: 12px 6px 10px;
+ border-radius: var(--radius-md);
+ padding: 13px 6px 11px;
background: var(--surface);
cursor: pointer;
+ color: inherit;
display: grid;
gap: 1px;
text-align: center;
+ transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
-.duration-button strong { font-size: 16px; }
+.duration-button:hover:not(:disabled) { border-color: color-mix(in srgb, var(--text) 45%, var(--border)); transform: translateY(-1px); }
+.duration-button strong { font-size: 17px; letter-spacing: -0.02em; }
.duration-button span { font-size: 9px; color: var(--faint); text-transform: uppercase; letter-spacing: .08em; }
-.duration-button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
-.duration-button.active span { color: color-mix(in srgb, var(--bg) 65%, transparent); }
+.duration-button.active { background: var(--selected-bg); color: var(--selected-text); border-color: var(--selected-bg); }
+.duration-button.active span { color: color-mix(in srgb, var(--selected-text) 65%, transparent); }
+.duration-button:disabled {
+ cursor: not-allowed;
+ opacity: .4;
+ background: var(--surface-soft);
+}
+.duration-button:disabled strong { text-decoration: line-through; text-decoration-thickness: 1px; }
+.duration-hint {
+ margin: 12px 0 0;
+ color: var(--faint);
+ font-size: 11px;
+ line-height: 1.5;
+}
.compact-details {
gap: 11px;
@@ -539,13 +636,23 @@ input, textarea, select {
color: var(--text);
background: var(--surface-soft);
border: 1px solid var(--border);
- border-radius: 11px;
+ border-radius: var(--radius-sm);
outline: 0;
padding: 11px 12px;
font-size: 13px;
resize: vertical;
}
input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var(--text) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--text) 7%, transparent); }
+input[aria-invalid="true"], textarea[aria-invalid="true"] {
+ border-color: color-mix(in srgb, var(--danger) 60%, var(--border));
+}
+input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 15%, transparent); }
+.field-error {
+ color: var(--danger);
+ font-size: 10px;
+ font-weight: 650;
+ letter-spacing: .01em;
+}
.optional { color: var(--faint); font-weight: 500; }
.honeypot { position: absolute !important; left: -99999px !important; }
.mode-picker { display: grid; gap: 8px; }
@@ -558,19 +665,24 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
text-align: left;
border: 1px solid var(--border);
background: var(--surface);
+ color: inherit;
padding: 11px;
- border-radius: 12px;
+ border-radius: var(--radius-md);
cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease;
}
+.mode-option:hover { border-color: color-mix(in srgb, var(--text) 35%, var(--border)); }
.mode-option span { display: grid; gap: 2px; }
.mode-option strong { font-size: 12px; }
.mode-option small { color: var(--faint); font-size: 9px; line-height: 1.35; font-weight: 500; }
.mode-option.active { border-color: var(--text); background: var(--surface-soft); }
+.mode-option svg { flex-shrink: 0; }
.primary-button {
border: 0;
- border-radius: 12px;
- padding: 12px 15px;
+ border-radius: var(--radius-md);
+ padding: 13px 16px;
+ min-height: var(--control-h);
background: var(--accent);
color: var(--accent-text);
display: inline-flex;
@@ -580,20 +692,42 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
font-weight: 720;
font-size: 12px;
cursor: pointer;
+ transition: opacity 120ms ease, transform 120ms ease;
}
.primary-button:hover { opacity: .9; }
+.primary-button:active { transform: translateY(1px); }
.primary-button:disabled { opacity: .5; cursor: wait; }
.inline-button { width: fit-content; }
+.secondary-button {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 12px 15px;
+ min-height: var(--control-h);
+ background: var(--surface);
+ color: var(--text);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-weight: 700;
+ font-size: 12px;
+ cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease;
+}
+.secondary-button:hover { border-color: color-mix(in srgb, var(--text) 40%, var(--border)); background: var(--surface-soft); }
.text-button {
border: 0;
background: transparent;
color: var(--muted);
font-size: 12px;
+ font-weight: 650;
cursor: pointer;
padding: 8px 0;
+ border-radius: var(--radius-xs);
}
+.text-button:hover { color: var(--text); }
.error-banner, .success-banner, .floating-error {
- border-radius: 10px;
+ border-radius: var(--radius-xs);
padding: 10px 12px;
font-size: 11px;
line-height: 1.4;
@@ -605,10 +739,12 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
left: 50%;
bottom: 56px;
transform: translateX(-50%);
- width: min(1180px, calc(100vw - 32px));
+ width: min(1200px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
margin: 0;
z-index: 30;
+ box-shadow: var(--shadow);
+ background: color-mix(in srgb, var(--danger) 12%, var(--surface));
}
.booking-page footer {
position: fixed;
@@ -646,7 +782,7 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
width: min(100%, 560px);
background: var(--surface);
border: 1px solid var(--border);
- border-radius: 24px;
+ border-radius: var(--shell-radius);
box-shadow: var(--shadow);
padding: 44px;
display: grid;
@@ -657,29 +793,72 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
height: 46px;
display: grid;
place-items: center;
- border-radius: 14px;
+ border-radius: var(--radius-md);
background: var(--surface-soft);
margin-bottom: 6px;
}
-.success-icon { color: var(--success); }
+.success-icon { color: var(--success); background: color-mix(in srgb, var(--success) 12%, var(--surface-soft)); }
.confirmation-card h1, .admin-login-card h1 { font-size: 39px; }
.confirmation-time { font-weight: 680; font-size: 14px; margin: 2px 0 0; }
.muted, .admin-login-card p { color: var(--muted); font-size: 13px; line-height: 1.55; margin: 0; }
.confirmation-panel {
justify-content: center;
- align-items: flex-start;
- gap: 14px;
+ align-items: stretch;
+ gap: 16px;
+ text-align: left;
}
+.confirmation-panel > .success-icon { align-self: flex-start; }
.confirmation-panel h2 {
margin: 0;
- font-size: 28px;
+ font-size: 27px;
letter-spacing: -0.04em;
}
+/* Confirmation detail summary — the "receipt" of the booking. */
+.confirmation-details {
+ display: grid;
+ gap: 1px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--border);
+ overflow: hidden;
+ margin: 4px 0 2px;
+}
+.confirm-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 11px 14px;
+ background: var(--surface);
+ font-size: 12px;
+}
+.confirm-row dt { color: var(--muted); font-weight: 600; }
+.confirm-row dd { margin: 0; color: var(--text); font-weight: 660; text-align: right; }
+.confirm-actions { display: grid; gap: 8px; margin-top: 4px; }
+.copy-button {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 12px 15px;
+ min-height: var(--control-h);
+ background: var(--surface);
+ color: var(--text);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-weight: 700;
+ font-size: 12px;
+ cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
+}
+.copy-button:hover { border-color: color-mix(in srgb, var(--text) 40%, var(--border)); background: var(--surface-soft); }
+.copy-button.copied { color: var(--success); border-color: color-mix(in srgb, var(--success) 45%, var(--border)); }
.admin-page { min-height: 100vh; padding: 22px 26px 40px; }
.admin-topbar { max-width: 1380px; margin-bottom: 24px; justify-content: flex-end; }
.admin-user { display: flex; align-items: center; gap: 13px; color: var(--muted); font-size: 11px; }
-.admin-user button { width: auto; padding: 0 11px; display: inline-flex; gap: 7px; border-radius: 9px; color: var(--muted); }
+.admin-user button { width: auto; padding: 0 12px; display: inline-flex; gap: 7px; align-items: center; border-radius: var(--radius-xs); color: var(--muted); }
+.admin-user button:hover { color: var(--text); background: var(--surface-soft); }
.admin-layout { max-width: 1380px; margin: auto; display: grid; grid-template-columns: 190px 1fr; gap: 28px; }
.admin-nav { padding: 10px 0; display: flex; flex-direction: column; gap: 5px; position: sticky; top: 18px; height: fit-content; }
.admin-nav .eyebrow { padding: 0 10px 8px; }
@@ -691,20 +870,21 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
align-items: center;
gap: 9px;
padding: 10px;
- border-radius: 9px;
+ border-radius: var(--radius-xs);
cursor: pointer;
font-size: 12px;
+ font-weight: 650;
text-align: left;
}
.admin-nav button.active, .admin-nav button:hover, .admin-nav a:hover { color: var(--text); background: var(--surface-strong); }
.admin-content { min-width: 0; }
.admin-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 20px; gap: 20px; }
.admin-heading h1 { font-size: 38px; }
-.primary-button.compact { padding: 10px 13px; }
+.primary-button.compact { padding: 10px 14px; }
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.settings-card, .booking-list-card {
border: 1px solid var(--border);
- border-radius: 18px;
+ border-radius: var(--radius-lg);
background: var(--surface);
padding: 22px;
}
@@ -732,7 +912,7 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
.theme-option em {
width: fit-content;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
- border-radius: 999px;
+ border-radius: var(--radius-pill);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
padding: 4px 9px;
@@ -748,7 +928,7 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
.theme-option {
min-height: 116px;
border: 1px solid var(--border);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface) 88%, transparent);
color: var(--text);
display: grid;
@@ -826,7 +1006,7 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
}
.availability-row:first-child { border-top: 0; }
.switch-row { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 12px; }
-.switch-row input { width: 15px; height: 15px; accent-color: var(--text); }
+.switch-row input { width: 15px; height: 15px; accent-color: var(--accent); }
.time-range { display: flex; align-items: center; gap: 8px; color: var(--faint); font-size: 10px; }
.time-range input { width: 108px; padding: 7px 8px; font-size: 11px; }
.unavailable { color: var(--faint); font-size: 10px; }
@@ -838,7 +1018,7 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
}
.timezone-control button {
border: 1px solid var(--border);
- border-radius: 11px;
+ border-radius: var(--radius-sm);
background: var(--surface-soft);
color: var(--text);
padding: 0 12px;
@@ -854,7 +1034,7 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
.checkbox-chips { display: flex; flex-wrap: wrap; gap: 7px; }
.checkbox-chips label, .mode-admin-grid label {
border: 1px solid var(--border);
- border-radius: 10px;
+ border-radius: var(--radius-xs);
padding: 8px 10px;
color: var(--muted);
display: inline-flex;
@@ -864,42 +1044,120 @@ input:focus, textarea:focus, select:focus { border-color: color-mix(in srgb, var
font-size: 10px;
}
.checkbox-chips input, .mode-admin-grid input { display: none; }
+.checkbox-chips label:has(:focus-visible), .mode-admin-grid label:has(:focus-visible) { box-shadow: var(--focus-ring); }
.checkbox-chips label.active, .mode-admin-grid label.active { border-color: var(--text); background: var(--surface-soft); color: var(--text); }
-.google-status-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 11px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-soft); }
+.google-status-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 11px 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-soft); }
.connection-badge { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 10px; font-weight: 700; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); }
.connection-badge.connected .status-dot { background: var(--success); box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 10%, transparent); }
.connection-actions { display: flex; align-items: center; gap: 10px; }
.connection-actions a, .connection-actions button { border: 0; background: transparent; color: var(--muted); font-size: 10px; padding: 0; cursor: pointer; }
.connection-actions a:hover, .connection-actions button:hover { color: var(--text); }
-select { width: 100%; color: var(--text); background: var(--surface-soft); border: 1px solid var(--border); border-radius: 11px; outline: 0; padding: 11px 12px; font-size: 13px; }
+select { width: 100%; color: var(--text); background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius-sm); outline: 0; padding: 11px 12px; font-size: 13px; }
.mode-admin-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.mode-admin-grid label { justify-content: center; padding: 11px 8px; }
+
+/* Admin bookings */
+.booking-tabs {
+ display: inline-flex;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ margin-bottom: 16px;
+}
+.booking-tab {
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ padding: 7px 14px;
+ border-radius: var(--radius-xs);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 700;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+.booking-tab:hover { color: var(--text); }
+.booking-tab.active { background: var(--surface-strong); color: var(--text); }
+.booking-tab .count {
+ font-size: 10px;
+ color: var(--faint);
+ font-variant-numeric: tabular-nums;
+}
+.booking-tab.active .count { color: var(--muted); }
.booking-list-card { padding: 0; overflow: hidden; }
.booking-list-row {
display: grid;
- grid-template-columns: 38px minmax(170px, 1fr) minmax(210px, 1fr) 80px;
+ grid-template-columns: 40px minmax(150px, 1.2fr) minmax(180px, 1.2fr) minmax(120px, .8fr) auto;
align-items: center;
- gap: 13px;
- padding: 14px 16px;
+ gap: 14px;
+ padding: 15px 18px;
border-top: 1px solid var(--border);
font-size: 11px;
}
.booking-list-row:first-child { border-top: 0; }
-.booking-avatar { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: var(--surface-soft); font-weight: 750; }
-.booking-person, .booking-when { display: grid; gap: 3px; min-width: 0; }
-.booking-person strong, .booking-when strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.booking-person span, .booking-when span { color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.booking-list-row > a { display: flex; align-items: center; gap: 5px; color: var(--muted); }
-.empty-list { padding: 42px; color: var(--muted); text-align: center; font-size: 12px; }
+.booking-list-row.past { opacity: .62; }
+.booking-avatar { width: 36px; height: 36px; border-radius: var(--radius-xs); display: grid; place-items: center; background: var(--surface-soft); font-weight: 750; font-size: 13px; }
+.booking-person, .booking-when, .booking-meta { display: grid; gap: 3px; min-width: 0; }
+.booking-person strong, .booking-when strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
+.booking-person span, .booking-when span, .booking-meta span { color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.booking-meta { font-size: 10px; }
+.booking-method { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-weight: 650; }
+.booking-method svg { flex-shrink: 0; }
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ width: fit-content;
+ padding: 3px 8px;
+ border-radius: var(--radius-pill);
+ font-size: 9px;
+ font-weight: 760;
+ letter-spacing: .05em;
+ text-transform: uppercase;
+ border: 1px solid var(--border);
+ color: var(--muted);
+}
+.status-pill.upcoming { color: var(--success); border-color: color-mix(in srgb, var(--success) 40%, var(--border)); background: color-mix(in srgb, var(--success) 8%, transparent); }
+.status-pill.past { color: var(--faint); }
+.booking-actions { display: inline-flex; align-items: center; gap: 6px; justify-self: end; }
+.icon-button {
+ width: var(--control-h-sm);
+ height: var(--control-h-sm);
+ border-radius: var(--radius-xs);
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--muted);
+ display: inline-grid;
+ place-items: center;
+ cursor: pointer;
+ transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
+}
+.icon-button:hover { color: var(--text); border-color: color-mix(in srgb, var(--text) 40%, var(--border)); background: var(--surface-soft); }
+.icon-button.copied { color: var(--success); border-color: color-mix(in srgb, var(--success) 45%, var(--border)); }
+.empty-list { padding: 52px 42px; color: var(--muted); text-align: center; display: grid; gap: 8px; justify-items: center; }
+.empty-list svg { color: var(--faint); }
+.empty-list strong { color: var(--text); font-size: 15px; letter-spacing: -0.02em; }
+.empty-list span { font-size: 12px; }
@media (max-width: 1180px) {
- .booking-shell { grid-template-columns: 240px minmax(460px, 1fr); }
- .selection-panel { grid-column: 1 / -1; border-left: 0 !important; border-top: 1px solid var(--border); min-height: 260px; }
+ .booking-shell { grid-template-columns: 250px minmax(440px, 1fr); }
+ .calendar-panel { padding: 34px 34px 36px; }
+ .selection-panel {
+ grid-column: 1 / -1;
+ border-left: 0 !important;
+ border-top: 1px solid var(--border);
+ min-height: 260px;
+ opacity: 1 !important;
+ }
.selection-panel .empty-state { min-height: 220px; }
.time-grid { grid-template-columns: repeat(6, 1fr); }
+ .slot-skeleton { grid-template-columns: repeat(6, 1fr); }
}
@media (max-width: 850px) {
@@ -923,13 +1181,14 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
height: auto;
max-height: none;
min-height: 0;
- border-radius: 20px;
+ border-radius: var(--radius-lg);
}
.booking-shell > * + * { border-left: 0; border-top: 1px solid var(--border); }
- .intro-panel { min-height: 290px; padding: 30px 24px; }
+ .intro-panel { min-height: 0; padding: 30px 24px; gap: 22px; }
.calendar-panel, .selection-panel { padding: 28px 22px; }
.day { min-height: 47px; }
.time-grid { grid-template-columns: repeat(4, 1fr); }
+ .slot-skeleton { grid-template-columns: repeat(4, 1fr); }
.admin-layout { grid-template-columns: 1fr; }
.admin-nav { position: static; flex-direction: row; overflow-x: auto; padding: 0; }
.settings-grid { grid-template-columns: 1fr; }
@@ -937,19 +1196,31 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
.appearance-heading { display: grid; }
.theme-grid { grid-template-columns: 1fr; }
.mode-admin-grid { grid-template-columns: repeat(2, 1fr); }
- .booking-list-row { grid-template-columns: 38px 1fr; }
- .booking-when { grid-column: 2; }
- .booking-list-row > a { grid-column: 2; }
+ .booking-list-row {
+ grid-template-columns: 40px 1fr auto;
+ grid-template-areas:
+ "avatar person actions"
+ "avatar when when"
+ "avatar meta meta";
+ row-gap: 6px;
+ }
+ .booking-avatar { grid-area: avatar; align-self: start; }
+ .booking-person { grid-area: person; }
+ .booking-when { grid-area: when; }
+ .booking-meta { grid-area: meta; }
+ .booking-actions { grid-area: actions; }
}
@media (max-width: 560px) {
.timezone-pill { display: none; }
- .calendar-panel { padding-left: 14px; padding-right: 14px; }
- .calendar-grid { gap: 3px; }
- .day { min-height: 41px; border-radius: 10px; }
- .day span { font-size: 11px; }
+ .booking-page { padding-top: 22px; }
+ .calendar-panel { padding-left: 16px; padding-right: 16px; }
+ .calendar-grid { gap: 4px; }
+ .day { min-height: 44px; border-radius: var(--radius-sm); }
+ .day span { font-size: 13px; }
.day small { display: none; }
.time-grid { grid-template-columns: repeat(3, 1fr); }
+ .slot-skeleton { grid-template-columns: repeat(3, 1fr); }
.duration-grid { grid-template-columns: repeat(4, 1fr); }
.form-row, .mode-options, .two-col-fields, .calendar-settings-row { grid-template-columns: 1fr; }
.timezone-control { grid-template-columns: 1fr; }
@@ -961,3 +1232,13 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
.theme-preview { width: 72px; height: 60px; }
.theme-preview span { width: 36px; height: 30px; }
}
+
+/* Respect users who prefer reduced motion: keep state changes instant. */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ scroll-behavior: auto !important;
+ }
+}