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/Manage.tsx
// One booking, for the person who booked it: when, the meeting link, add to calendar, move it, cancel it.
// "#c=<cancelKey>" (the manage link) opens it on any device; the device that booked needs no key.
// A moved booking forwards to the one that replaced it, which keeps the same cancel key.
import { useEffect, useState } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { Modal, toast, copy, download, CalendarIcon, CheckIcon, VideoIcon } from "./ui";
import { BROWSER_TZ, LoadError, useNow } from "./BookingPage";
import { SITE_URL } from "../functions/lib/config";
import { cellsFor, formatDay, formatTime, zoneLabel, meetingEvent, icsFile, googleCalendarUrl, fileName, parseAnswers, type Booking, type Page } from "../functions/lib/book";
function ManageSkeleton() {
return (
<div className="wrap page-pad manage" aria-busy="true" aria-label="Opening your booking">
<section className="card meet-card">
<span className="skeleton skeleton-text sk-name" />
<span className="skeleton sk-title" />
<span className="skeleton skeleton-text sk-fact" />
<span className="skeleton sk-link" />
</section>
</div>
);
}
export function Manage({ handle, bookingId }: { handle: string; bookingId: string }) {
const [state, setState] = useState<"loading" | "ready" | "missing" | "error">("loading");
const [key, setKey] = useState<string | null>(null);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const uid = await me();
const fromLink = new URLSearchParams(location.hash.slice(1)).get("c");
const k = fromLink || localStorage.getItem(`book:key:${bookingId}`);
if (fromLink) await set(`bookingclaims/${bookingId}/c/${uid}`, { key: fromLink }).catch(() => null);
const b = (await get(`pages/${handle}/bookings/${bookingId}`)) as Booking | null;
if (!b) { if (!cancelled) setState("missing"); return; }
if (b.status === "cancelled" && b.movedTo && k) {
history.replaceState(null, "", `/b/${handle}/${b.movedTo}#c=${k}`);
window.dispatchEvent(new PopStateEvent("popstate"));
return;
}
if (fromLink) {
localStorage.setItem(`book:key:${bookingId}`, fromLink);
// Lets this device read the meeting link.
if (b.by !== uid) await set(`pages/${handle}/bookers/${uid}`, { bookingId }).catch(() => null);
}
if (!cancelled) { setKey(k); setState("ready"); }
} catch (err) {
if (!cancelled) { setError(explain(err)); setState("error"); }
}
})();
return () => { cancelled = true; };
}, [handle, bookingId]);
if (state === "loading") return <ManageSkeleton />;
if (state === "error") return <LoadError text={error} />;
if (state === "missing") return (
<div className="wrap page-pad narrow">
<div className="empty-state">
<h2>This booking link does not open</h2>
<p>Check that you copied all of it, including the part after the #. Every character counts.</p>
<a className="btn btn-line" href={`/${handle}`} onClick={(e) => { e.preventDefault(); navigate(`/${handle}`); }}>Go to the booking page</a>
</div>
</div>
);
return <BookingView bookingId={bookingId} handle={handle} cancelKey={key} />;
}
function BookingView({ bookingId, handle, cancelKey }: { bookingId: string; handle: string; cancelKey: string | null }) {
const { data: booking } = useQuery(`pages/${handle}/bookings/${bookingId}`) as { data: Booking | null | undefined };
const { data: page } = useQuery(`pages/${handle}`) as { data: Page | null | undefined };
const [meetingUrl, setMeetingUrl] = useState("");
const [fresh] = useState(() => localStorage.getItem(`book:new:${bookingId}`));
const [confirm, setConfirm] = useState(false);
const now = useNow();
const booked = booking?.status === "booked";
useEffect(() => {
if (!booked) return;
get(`pagelinks/${handle}`).then((l: any) => setMeetingUrl(String(l?.meetingUrl ?? "")), () => null);
}, [booked, handle]);
useEffect(() => {
if (page) document.title = `${page.title} with ${page.name} | Book`;
}, [page?.title, page?.name]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => () => { localStorage.removeItem(`book:new:${bookingId}`); }, [bookingId]);
if (booking === undefined || page === undefined) return <ManageSkeleton />;
if (!booking || !page) return <LoadError text="This booking could not be loaded." />;
const zone = BROWSER_TZ;
const manageUrl = cancelKey ? `${SITE_URL}/b/${handle}/${bookingId}#c=${cancelKey}` : "";
const event = meetingEvent(bookingId, page, booking, meetingUrl, manageUrl || `${SITE_URL}/b/${handle}/${bookingId}`);
const over = booking.end <= now;
const answers = parseAnswers(booking.answers);
const status = !booked ? (booking.movedTo ? "Moved" : "Cancelled") : over ? "This meeting has passed" : "Booked";
const toPage = (path: string) => (e: React.MouseEvent) => { e.preventDefault(); navigate(path); };
return (
<div className="wrap page-pad manage">
{fresh && booked ? (
<header className="done-head enter">
<span className="done-mark" aria-hidden="true"><CheckIcon /></span>
<h1 className="h1">{fresh === "moved" ? "Your booking moved." : "You are booked."}</h1>
<p className="lede">{fresh === "moved"
? "Your old time is free again. If the old time is in your calendar, delete it and add this one."
: "Book sends no emails, so add the meeting to your calendar now and keep your booking link."}</p>
</header>
) : (
<header className="done-head">
<p className="eyebrow">Your booking</p>
</header>
)}
<section className={`card meet-card${booked ? "" : " meet-off"}`}>
<p className="eyebrow">{status}</p>
<h2 className="meet-title">{page.title} with {page.name}</h2>
<p className="meet-day">{formatDay(booking.start, zone)}</p>
<p className="meet-time">{formatTime(booking.start, zone)} to {formatTime(booking.end, zone)}, {zoneLabel(zone, [booking.start])}</p>
{booked ? (
<>
<div className="meet-link">
<span className="meet-icon"><VideoIcon /></span>
<div className="meet-link-text">
<span className="label">{page.meeting}</span>
{meetingUrl ? <a href={meetingUrl} target="_blank" rel="noopener noreferrer">{meetingUrl}</a> : <span className="skeleton skeleton-text sk-url" aria-label="Loading the meeting link" />}
</div>
{meetingUrl && <button className="btn btn-sm btn-line" type="button" onClick={() => copy(meetingUrl, "Meeting link copied")}>Copy</button>}
</div>
<div className="row meet-actions">
<button className="btn" type="button" disabled={!meetingUrl} onClick={() => download(`${fileName(event.title)}.ics`, icsFile(event), "text/calendar")}>
<CalendarIcon /> Add to calendar
</button>
<a className="btn btn-line" aria-disabled={!meetingUrl || undefined} href={meetingUrl ? googleCalendarUrl(event) : undefined} target="_blank" rel="noopener noreferrer">Google Calendar</a>
</div>
</>
) : (
<>
<p className="notice" style={{ marginTop: 16 }}>
{booking.movedTo ? "You moved this booking to another time. Open the link you got when you moved it."
: booking.cancelledBy === "owner" ? `${page.name} cancelled this booking. The time is free again.`
: "This booking is cancelled. The time is free again."}
</p>
<a className="btn" style={{ marginTop: 16 }} href={`/${handle}`} onClick={toPage(`/${handle}`)}>Book another time</a>
</>
)}
</section>
{booked && !over && (
<section className="keep-box">
<h3 className="h3">Need to change plans?</h3>
<div className="row keep-actions">
<a className="btn btn-line" href={`/${handle}?move=${bookingId}`} onClick={toPage(`/${handle}?move=${bookingId}`)}>Change time</a>
<button className="btn btn-quiet btn-danger" type="button" onClick={() => setConfirm(true)}>Cancel booking</button>
</div>
{manageUrl ? (
<>
<p className="muted">This link opens your booking on any device, to move or cancel it. Anyone with it can do the same, so keep it to yourself.</p>
<div className="link-row"><code className="link-code">{manageUrl}</code><button className="btn btn-sm btn-line" type="button" onClick={() => copy(manageUrl, "Booking link copied. Keep it private.")}>Copy</button></div>
</>
) : (
<p className="muted">This device made the booking, so it can move or cancel it here.</p>
)}
</section>
)}
<section className="you-gave">
<h3 className="your-title">What you sent</h3>
<dl>
<div><dt>Name</dt><dd>{booking.name}</dd></div>
<div><dt>Email</dt><dd>{booking.email}</dd></div>
{answers.map((a, i) => <div key={i}><dt>{a.q}</dt><dd>{a.a}</dd></div>)}
{booking.note && <div><dt>Note</dt><dd>{booking.note}</dd></div>}
</dl>
</section>
{confirm && <CancelSheet handle={handle} bookingId={bookingId} booking={booking} page={page} onClose={() => setConfirm(false)} />}
</div>
);
}
function CancelSheet({ handle, bookingId, booking, page, onClose }: { handle: string; bookingId: string; booking: Booking; page: Page; onClose: () => void }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const cancel = async (close: () => void) => {
setBusy(true);
try {
await setMany([
{ path: `pages/${handle}/bookings/${bookingId}`, document: { status: "cancelled", cancelledBy: "guest", cancelledAt: serverTimestamp() } },
...cellsFor(booking.start, booking.heldUntil).map((c) => ({ path: `pages/${handle}/slots/${c}`, document: null })),
]);
toast("Booking cancelled. The time is free again.");
close();
} catch (e) {
setError(explain(e));
setBusy(false);
}
};
return (
<Modal title="Cancel this booking?" onClose={onClose} footer={(close) => <>
<button className="btn btn-line" type="button" onClick={close}>Keep it</button>
<button className="btn" type="button" onClick={() => cancel(close)} aria-busy={busy || undefined}>Cancel booking</button>
</>}>
<p>The time opens up for others, and {page.name} sees the booking as cancelled. Book sends no emails, so tell {page.name} yourself if you can.</p>
<p className="muted">To keep the meeting at another time, choose Change time instead.</p>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</Modal>
);
}
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