Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7385a6c1c47153b1728160ff
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/BookingPage.tsx
// A public booking page: pick a day, then a time in your own time zone, then say who you are.
// Taken time disappears for everyone as soon as it is booked (the page listens to its cells).
// Links can carry ?date=YYYY-MM-DD, ?tz=Area/City, ?name= and ?email= to start on a day, in a zone, with the form filled.
// ?move=<bookingId> moves that booking to the time picked, in one batch that frees the old time.
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { get, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { weekOrder, zoneOptions } from "./PageForm";
import { bringIntoView, BackIcon, ChevronLeft, ChevronRight, ClockIcon, GlobeIcon, VideoIcon } from "./ui";
import {
openSlots, cellsFor, parseQuestions, parseAnswers, wallClock, dateToUtcDay, formatTime, formatDay, zoneLabel, zoneCity, randomId,
validTimeZone, EMAIL_RE, LIMITS, CELL, WEEKDAYS, type Booking, type Page,
} from "../functions/lib/book";
export const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
export function useNow(every = 60_000): number {
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const t = window.setInterval(() => setNow(Math.floor(Date.now() / 1000)), every);
return () => window.clearInterval(t);
}, [every]);
return now;
}
/** A failed load, with the one next step there is. */
export function LoadError({ text }: { text: string }) {
return (
<div className="wrap page-pad narrow">
<div className="error-state">
<h2>Could not open this page</h2>
<p>{text}</p>
<button className="btn" type="button" onClick={() => location.reload()}>Try again</button>
</div>
</div>
);
}
function BookingSkeleton() {
return (
<div className="bp" aria-busy="true" aria-label="Loading open times">
<div className="wrap bp-wrap">
<section className="card bp-card">
<aside className="bp-info">
<span className="skeleton skeleton-text sk-name" />
<span className="skeleton sk-title" />
<span className="skeleton skeleton-text sk-fact" />
<span className="skeleton skeleton-text sk-fact" />
</aside>
<div className="bp-main">
<span className="skeleton skeleton-text sk-h" />
<span className="skeleton skeleton-text sk-month" />
<div className="cal-grid sk-cal">{Array.from({ length: 35 }, (_, i) => <span key={i} className="skeleton sk-day" />)}</div>
</div>
</section>
</div>
</div>
);
}
export function BookingPage({ handle }: { handle: string }) {
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
useEffect(() => {
me().then(() => setState("ready"), (err) => { setError(explain(err)); setState("error"); });
}, []);
if (state === "loading") return <BookingSkeleton />;
if (state === "error") return <LoadError text={error} />;
return <PageView handle={handle} />;
}
type Moving = { booking: Booking & { id: string }; key: string };
/**
* The booking named by ?move=, if this device can move it (it made the booking, or opened its manage link).
* "done" when it is already moved, cancelled or over; "denied" when this device cannot read it.
*/
function useMove(handle: string, id: string | null): Moving | "done" | "denied" | null | undefined {
const [move, setMove] = useState<Moving | "done" | "denied" | null | undefined>(id ? undefined : null);
useEffect(() => {
if (!id) return;
get(`pages/${handle}/bookings/${id}`).then(
(b: any) => setMove(!b ? "denied" : b.status !== "booked" || b.end <= Date.now() / 1000 ? "done" : { booking: { ...b, id }, key: localStorage.getItem(`book:key:${id}`) || "" }),
() => setMove("denied"),
);
}, [handle, id]);
return move;
}
function PageView({ handle }: { handle: string }) {
const [params] = useState(() => new URLSearchParams(location.search));
const { data: page } = useQuery(`pages/${handle}`) as { data: Page | null | undefined };
const since = useMemo(() => Math.floor(Date.now() / 1000 / CELL) * CELL - 86400, []);
const { data: cells } = useQuery(`pages/${handle}/slots`, { filter: { at: { $gte: since } }, limit: 1000 }) as { data: { at: number }[] | undefined };
const move = useMove(handle, params.get("move"));
const now = useNow();
const [zone, setZone] = useState(() => (validTimeZone(params.get("tz")) ? params.get("tz")! : BROWSER_TZ));
const [pickZone, setPickZone] = useState(false);
const [day, setDay] = useState<string | null>(params.get("date"));
const [pick, setPick] = useState<number | null>(null);
const [lost, setLost] = useState(false);
const submitting = useRef(false);
const timesRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLDivElement>(null);
const taken = useMemo(() => new Set((cells || []).map((c) => Number(c.at))), [cells]);
const slots = useMemo(() => (page ? openSlots(page, taken, now, now, now + page.horizonDays * 86400 + 1) : []), [page, taken, now]);
const byDay = useMemo(() => {
const m = new Map<string, number[]>();
for (const s of slots) {
const d = wallClock(s, zone).date;
m.set(d, [...(m.get(d) || []), s]);
}
return m;
}, [slots, zone]);
// Someone else booked the time this person was filling in.
useEffect(() => {
if (pick != null && cells && !slots.includes(pick) && !submitting.current) { setPick(null); setLost(true); }
}, [slots]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (page) document.title = `${page.title} with ${page.name} | Book`;
}, [page?.title, page?.name]); // eslint-disable-line react-hooks/exhaustive-deps
const zones = useMemo(() => zoneOptions(zone), [zone]);
if (page === undefined || cells === undefined || move === undefined) return <BookingSkeleton />;
if (!page) return (
<div className="wrap page-pad narrow">
<div className="empty-state">
<img className="art" src="/art/card-book.webp" alt="" width="180" height="145" />
<h2>No booking page here</h2>
<p>Check the link you were sent. If you meant to make this page, the address /{handle} is free.</p>
<a className="btn" href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Make a booking page</a>
</div>
</div>
);
const moving = typeof move === "object" ? move : null;
// The chosen day (or the ?date= one) while it has open times; otherwise the first day that does.
const shownDay = day && byDay.has(day) ? day : byDay.keys().next().value ?? null;
const times = shownDay ? byDay.get(shownDay)! : [];
const zoneName = zoneLabel(zone, times.length ? times : slots.slice(0, 1));
const choose = (d: string) => { setDay(d); setLost(false); requestAnimationFrame(() => bringIntoView(timesRef.current)); };
const choosePick = (s: number) => { setPick(s); setLost(false); requestAnimationFrame(() => bringIntoView(mainRef.current)); };
return (
<div className="bp">
<div className="wrap bp-wrap">
<section className="card bp-card">
<aside className="bp-info">
<p className="bp-name">{page.name}</p>
<h1 className="bp-title">{page.title}</h1>
<ul className="bp-facts">
<li><ClockIcon /> {page.durationMin} minutes</li>
<li><VideoIcon /> {page.meeting}</li>
</ul>
{page.description && <p className="bp-desc">{page.description}</p>}
</aside>
<div className="bp-main" ref={mainRef}>
{move === "denied" && <p className="notice notice-error">This device cannot move that booking. Open your booking link first, then choose Change time.</p>}
{move === "done" && <p className="notice">That booking is already moved, cancelled or over. You can book a new time here.</p>}
{moving && (
<p className="notice move-note">
Moving your booking on <strong>{formatDay(moving.booking.start, zone)} at {formatTime(moving.booking.start, zone)}</strong>. It stays booked until you confirm a new time.
</p>
)}
{pick == null ? (
<div className="bp-step enter" key="pick">
<h2 className="bp-h">{moving ? "Pick a new time" : "Pick a day and time"}</h2>
{lost && <p className="notice notice-error" role="alert">Someone just booked that time. Pick another one.</p>}
{!slots.length ? (
<div className="empty-state">
<img className="art" src="/art/card-book.webp" alt="" width="180" height="145" />
<h3>No open times right now</h3>
<p>{page.name} has no free times in the next {page.horizonDays === 1 ? "day" : `${page.horizonDays} days`}. Check back later.</p>
</div>
) : (
<div className={`bp-pick${shownDay ? " has-day" : ""}`}>
<MonthCalendar open={byDay} selected={shownDay} onSelect={choose} zone={zone} now={now} />
{shownDay && (
<div className="bp-times enter" key={`${shownDay}|${zone}`} ref={timesRef}>
<p className="bp-day" id="bp-day">{formatDay(times[0], zone)}</p>
<ul className="time-list" aria-labelledby="bp-day">
{times.map((s) => (
<li key={s}><button className="time-btn press" type="button" onClick={() => choosePick(s)}>{formatTime(s, zone)}</button></li>
))}
</ul>
</div>
)}
</div>
)}
<div className="tz-bar">
<GlobeIcon />
{pickZone ? (
<select className="select select-inline" value={zone} onChange={(e) => setZone(e.target.value)} aria-label="Show times in" autoFocus>
{zones.map((z) => <option key={z} value={z}>{z.replace(/_/g, " ")}</option>)}
</select>
) : (
<span>Times in <strong>{zoneName}</strong> <button className="link-btn" type="button" onClick={() => setPickZone(true)}>Change</button></span>
)}
</div>
</div>
) : (
<BookForm key={pick} handle={handle} page={page} start={pick} zone={zone} move={moving} prefill={params} submitting={submitting}
onBack={() => setPick(null)} onLost={() => { setPick(null); setLost(true); }} />
)}
</div>
</section>
<p className="bp-foot">A free booking page made with <a href="/">Book</a>. <a href="/">Make your own</a></p>
</div>
</div>
);
}
function MonthCalendar({ open, selected, onSelect, zone, now }: { open: Map<string, number[]>; selected: string | null; onSelect: (d: string) => void; zone: string; now: number }) {
const today = wallClock(now, zone).date;
const dates = [...open.keys()];
const lastDate = dates[dates.length - 1] ?? today;
const order = useMemo(weekOrder, []);
const startOn = WEEKDAYS.indexOf(order[0]);
const [view, setView] = useState(() => {
const [y, m] = (selected ?? dates[0] ?? today).split("-").map(Number);
return { y, m: m - 1 };
});
useEffect(() => {
if (!selected) return;
const [y, m] = selected.split("-").map(Number);
setView((v) => (v.y === y && v.m === m - 1 ? v : { y, m: m - 1 }));
}, [selected]);
const key = (y: number, m: number) => y * 12 + m;
const [ty, tm] = today.split("-").map(Number);
const [ly, lm] = lastDate.split("-").map(Number);
const atFirst = key(view.y, view.m) <= key(ty, tm - 1);
const atLast = key(view.y, view.m) >= key(ly, lm - 1);
const move = (delta: number) => setView(({ y, m }) => { const n = key(y, m) + delta; return { y: Math.floor(n / 12), m: n % 12 }; });
const pad = (n: number) => String(n).padStart(2, "0");
const firstWeekday = new Date(Date.UTC(view.y, view.m, 1)).getUTCDay();
const blanks = (firstWeekday - startOn + 7) % 7;
const days = new Date(Date.UTC(view.y, view.m + 1, 0)).getUTCDate();
const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: "short" }).format(new Date(Date.UTC(2026, 9, 18 + startOn + i))));
const monthName = new Intl.DateTimeFormat(undefined, { timeZone: "UTC", month: "long", year: "numeric" }).format(new Date(Date.UTC(view.y, view.m, 1)));
const longDate = (date: string) => new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: "long", month: "long", day: "numeric" }).format(new Date(dateToUtcDay(date) * 1000));
return (
<div className="cal">
<div className="cal-head">
<button className="btn btn-quiet btn-icon btn-sm" type="button" onClick={() => move(-1)} disabled={atFirst} aria-label="Previous month"><ChevronLeft /></button>
<span className="cal-month" aria-live="polite">{monthName}</span>
<button className="btn btn-quiet btn-icon btn-sm" type="button" onClick={() => move(1)} disabled={atLast} aria-label="Next month"><ChevronRight /></button>
</div>
<div className="cal-grid" role="group" aria-label={monthName}>
{weekdays.map((w, i) => <span className="cal-wd" key={i} aria-hidden="true">{w}</span>)}
{Array.from({ length: blanks }, (_, i) => <span key={`b${i}`} />)}
{Array.from({ length: days }, (_, i) => {
const date = `${view.y}-${pad(view.m + 1)}-${pad(i + 1)}`;
const count = open.get(date)?.length ?? 0;
return (
<button key={date} type="button" className={`cal-day${count ? " open" : ""}${selected === date ? " on" : ""}${date === today ? " today" : ""}`}
aria-pressed={selected === date} aria-label={`${longDate(date)}${count ? `, ${count} open ${count === 1 ? "time" : "times"}` : ", no open times"}`}
disabled={!count} onClick={() => onSelect(date)}>
{i + 1}
</button>
);
})}
</div>
</div>
);
}
const ME_KEY = "book:me";
type Errors = Partial<Record<string, string>>;
function BookForm({ handle, page, start, zone, move, prefill, submitting, onBack, onLost }: {
handle: string; page: Page; start: number; zone: string; move: Moving | null; prefill: URLSearchParams;
submitting: React.MutableRefObject<boolean>; onBack: () => void; onLost: () => void;
}) {
const saved = useMemo(() => { try { return JSON.parse(localStorage.getItem(ME_KEY) || "{}"); } catch { return {}; } }, []);
const questions = useMemo(() => parseQuestions(page.questions), [page.questions]);
const old = move?.booking;
const oldAnswers = useMemo(() => parseAnswers(old?.answers), [old]);
const [name, setName] = useState<string>(old?.name ?? prefill.get("name") ?? saved.name ?? "");
const [email, setEmail] = useState<string>(old?.email ?? prefill.get("email") ?? saved.email ?? "");
const [note, setNote] = useState(old?.note ?? "");
const [answers, setAnswers] = useState<string[]>(() => questions.map((q) => oldAnswers.find((a) => a.q === q.label)?.a ?? ""));
const [busy, setBusy] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => { formRef.current?.querySelector<HTMLElement>(move ? "button[type=submit]" : "input")?.focus({ preventScroll: true }); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const end = start + page.durationMin * 60;
const field = (id: string) => ({ id: `bf-${id}`, "aria-invalid": errors[id] ? true : undefined, "aria-describedby": errors[id] ? `bf-${id}-error` : undefined });
const fieldError = (id: string) => errors[id] && <span className="field-error" id={`bf-${id}-error`}>{errors[id]}</span>;
const submit = async (e: FormEvent) => {
e.preventDefault();
const n = name.trim();
const em = email.trim();
const found: Errors = {};
if (!n) found.name = "Type your name.";
if (!EMAIL_RE.test(em)) found.email = em ? "This does not look like an email address." : "Type your email, so the host can reach you.";
questions.forEach((q, i) => { if (q.required && !answers[i]?.trim()) found[`q${i}`] = "Answer this question to book."; });
setErrors(found);
setError(null);
const first = Object.keys(found)[0];
if (first) return document.getElementById(`bf-${first}`)?.focus();
setBusy(true);
// Our own booking's cells arrive live too; do not mistake them for someone else's.
submitting.current = true;
try {
const uid = await me();
const bookingId = randomId(20);
// A moved booking keeps its cancel key, so the guest's link opens the new time too.
const key = move?.key || randomId(24);
const heldUntil = end + page.bufferMin * 60;
const cells = cellsFor(start, heldUntil);
const filled = questions.map((q, i) => ({ q: q.label, a: (answers[i] || "").trim() })).filter((x) => x.a);
await setMany([
{ path: `pages/${handle}/bookings/${bookingId}`, document: { start, end, heldUntil, name: n, email: em, note: note.trim(), answers: JSON.stringify(filled), status: "booked", by: uid, createdAt: serverTimestamp() } },
...cells.map((c, i) => ({ path: `pages/${handle}/slots/${c}`, document: { bookingId, at: c, ...(i < cells.length - 1 ? { next: c + CELL } : {}) } })),
{ path: `bookingkeys/${bookingId}`, document: { key, handle } },
{ path: `pages/${handle}/bookers/${uid}`, document: { bookingId } },
...(old ? [
{ path: `pages/${handle}/bookings/${old.id}`, document: { status: "cancelled", cancelledBy: "guest", cancelledAt: serverTimestamp(), movedTo: bookingId } },
...cellsFor(old.start, old.heldUntil).map((c) => ({ path: `pages/${handle}/slots/${c}`, document: null })),
] : []),
]);
localStorage.setItem(ME_KEY, JSON.stringify({ name: n, email: em }));
localStorage.setItem(`book:key:${bookingId}`, key);
localStorage.setItem(`book:new:${bookingId}`, old ? "moved" : "booked");
navigate(`/b/${handle}/${bookingId}#c=${key}`);
} catch (err) {
submitting.current = false;
setBusy(false);
const cells = cellsFor(start, end + page.bufferMin * 60);
const held = await Promise.all(cells.map((c) => get(`pages/${handle}/slots/${c}`).catch(() => null)));
if (held.some(Boolean)) onLost();
else setError(/could not save/.test(explain(err)) ? "That time can no longer be booked. Pick another time." : explain(err));
}
};
return (
<form className="book-form enter" onSubmit={submit} noValidate ref={formRef}>
<button className="link-btn back-link" type="button" onClick={onBack}><BackIcon /> Pick another time</button>
<div className="chosen">
<h2 className="bp-h">{formatDay(start, zone)}</h2>
<p className="chosen-time">{formatTime(start, zone)} to {formatTime(end, zone)} <span className="muted">{zoneCity(zone)} time</span></p>
</div>
<label className="field">
<span>Name</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} maxLength={LIMITS.name} autoComplete="name" {...field("name")} />
{fieldError("name")}
</label>
<label className="field">
<span>Email</span>
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} maxLength={LIMITS.email} autoComplete="email" inputMode="email" {...field("email")} />
{fieldError("email")}
</label>
{questions.map((q, i) => (
<label className="field" key={i}>
<span>{q.label}{!q.required && <span className="optional">optional</span>}</span>
<textarea className="textarea textarea-sm" value={answers[i]} onChange={(e) => setAnswers(answers.map((a, j) => (j === i ? e.target.value : a)))} maxLength={LIMITS.answer} rows={2} {...field(`q${i}`)} />
{fieldError(`q${i}`)}
</label>
))}
<label className="field">
<span>Note <span className="optional">optional</span></span>
<textarea className="textarea textarea-sm" value={note} onChange={(e) => setNote(e.target.value)} maxLength={LIMITS.note} rows={2} placeholder="Anything to share before the call" />
</label>
{error && <p className="notice notice-error" role="alert">{error}</p>}
<button className="btn btn-lg btn-block" type="submit" aria-busy={busy || undefined}>{old ? "Move my booking" : "Confirm booking"}</button>
<p className="hint">{old
? "Your old time opens up for others as soon as the new one is booked."
: "Next you get the meeting link, a calendar invite and your link to move or cancel. Book sends no emails."}</p>
</form>
);
}
Change history
Add favicon.ico and link tag
Quality pass: guest reschedule, days off, calendar feed, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync