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/Dashboard.tsx
// The owner's dashboard: upcoming and past bookings, cancel one, edit the page, copy its link, subscribe to bookings.
// "/e/<handle>#k=<key>" (the admin link) makes this device an owner; the key leaves the address bar.
import { useEffect, useMemo, useState } from "react";
import { get, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain, SERVED_APP_ID } from "./lib/bounded";
import { navigate } from "./lib/router";
import { adminKey, rememberPage } from "./lib/pages";
import { Modal, Dropdown, toast, copy, download, CloseIcon } from "./ui";
import { LoadError, useNow } from "./BookingPage";
import { PageForm, SITE_HOST } from "./PageForm";
import { SITE_URL } from "../functions/lib/config";
import { cellsFor, wallClock, formatDay, formatTime, zoneLabel, meetingEvent, icsFile, feedKey, fileName, parseAnswers, type Booking, type Page } from "../functions/lib/book";
type Row = Booking & { id: string };
const PAST_DAYS = 90;
function DashSkeleton() {
return (
<div className="dash" aria-busy="true" aria-label="Opening your page">
<header className="dash-head">
<div className="wrap dash-head-inner">
<div className="dash-title-block">
<span className="skeleton skeleton-text sk-name" />
<span className="skeleton sk-title sk-dash-title" />
</div>
</div>
<div className="wrap"><div className="tabs"><span className="skeleton skeleton-text sk-tab" /></div></div>
</header>
<div className="wrap"><div className="dash-body">
<span className="skeleton sk-row" />
<span className="skeleton sk-row" />
</div></div>
</div>
);
}
export function Dashboard({ handle }: { handle: string }) {
const [state, setState] = useState<"loading" | "ready" | "missing" | "error">("loading");
const [owner, setOwner] = useState(false);
const [claim, setClaim] = useState<"ok" | "bad" | null>(null);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const uid = await me();
if (!(await get(`pages/${handle}`))) { if (!cancelled) setState("missing"); return; }
let isOwner = Boolean(await get(`pages/${handle}/owners/${uid}`));
const key = new URLSearchParams(location.hash.slice(1)).get("k");
if (key) {
if (!isOwner) {
try {
await setMany([
{ path: `pageclaims/${handle}/c/${uid}`, document: { key } },
{ path: `pages/${handle}/owners/${uid}`, document: { at: serverTimestamp() } },
]);
isOwner = true;
} catch {
if (!cancelled) setClaim("bad");
}
}
if (isOwner) {
rememberPage({ handle, key });
if (!cancelled) setClaim("ok");
}
// Drop the key from the address bar so it is not shared by accident.
history.replaceState(null, "", `/e/${handle}`);
}
if (!cancelled) { setOwner(isOwner); setState("ready"); }
} catch (err) {
if (!cancelled) { setError(explain(err)); setState("error"); }
}
})();
return () => { cancelled = true; };
}, [handle]);
const toPage = (path: string) => (e: React.MouseEvent) => { e.preventDefault(); navigate(path); };
if (state === "loading") return <DashSkeleton />;
if (state === "error") return <LoadError text={error} />;
if (state === "missing") return (
<div className="wrap page-pad narrow">
<div className="empty-state">
<h2>No booking page at {SITE_HOST}/{handle}</h2>
<p>The address is free. Make a booking page and it can be yours.</p>
<a className="btn" href="/" onClick={toPage("/")}>Make a booking page</a>
</div>
</div>
);
if (!owner) return (
<div className="wrap page-pad narrow">
{claim === "bad" && <p className="notice notice-error" style={{ marginBottom: 20 }}>That admin link did not work. Check that you copied all of it, including the part after the #.</p>}
<div className="empty-state">
<h2>Only the page's owner sees its bookings</h2>
<p>If this is your page, open your admin link on this device. To book a time, go to the booking page.</p>
<a className="btn" href={`/${handle}`} onClick={toPage(`/${handle}`)}>Go to the booking page</a>
</div>
</div>
);
return <OwnerView handle={handle} claimed={claim === "ok"} />;
}
/** The page's bookings as a calendar feed, for the device that holds the admin key. */
function useFeedUrl(handle: string, key: string | undefined): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (key) feedKey(key).then((f) => setUrl(`https://functions.bounded.sh/apps/${SERVED_APP_ID}/api/pages/${handle}/calendar.ics?f=${f}`));
}, [handle, key]);
return url;
}
function FeedMenu({ url, onUse, end }: { url: string; onUse: () => void; end?: boolean }) {
const webcal = url.replace(/^https:/, "webcal:");
return (
<Dropdown label="Add to my calendar" end={end}>
<a href={`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`} target="_blank" rel="noopener noreferrer" onClick={onUse}>Google Calendar</a>
<a href={webcal} onClick={onUse}>Apple Calendar</a>
<hr />
<button type="button" onClick={() => { onUse(); copy(url, "Feed link copied. In Outlook, add a calendar from the internet and paste it."); }}>Copy the feed link</button>
</Dropdown>
);
}
function OwnerView({ handle, claimed }: { handle: string; claimed: boolean }) {
const since = useMemo(() => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400, []);
const { data: page } = useQuery(`pages/${handle}`) as { data: Page | null | undefined };
const { data: rows } = useQuery(`pages/${handle}/bookings`, { filter: { end: { $gte: since } }, limit: 1000 }) as { data: Row[] | undefined };
const [meetingUrl, setMeetingUrl] = useState<string | null>(null);
const [tab, setTab] = useState<"bookings" | "settings">("bookings");
const [isNew, setIsNew] = useState(() => localStorage.getItem(`book:new:${handle}`) === "1");
const [feedUsed, setFeedUsed] = useState(() => localStorage.getItem(`book:feed:${handle}`) === "1");
const [cancelling, setCancelling] = useState<Row | null>(null);
const now = useNow();
const key = adminKey(handle);
const feedUrl = useFeedUrl(handle, key);
useEffect(() => {
get(`pagelinks/${handle}`).then((l: any) => setMeetingUrl(String(l?.meetingUrl ?? "")), () => setMeetingUrl(""));
}, [handle, page?.meeting]);
useEffect(() => {
if (!page) return;
document.title = `${page.title}: your bookings | Book`;
rememberPage({ handle, title: page.title });
}, [page?.title]); // eslint-disable-line react-hooks/exhaustive-deps
const { upcoming, past, cancelled, byId, movedFrom } = useMemo(() => {
const all = rows || [];
return {
upcoming: all.filter((b) => b.status === "booked" && b.end > now).sort((a, b) => a.start - b.start),
past: all.filter((b) => b.status === "booked" && b.end <= now).sort((a, b) => b.start - a.start),
cancelled: all.filter((b) => b.status === "cancelled").sort((a, b) => b.start - a.start),
byId: new Map(all.map((b) => [b.id, b])),
movedFrom: new Map(all.filter((b) => b.movedTo).map((b) => [b.movedTo!, b])),
};
}, [rows, now]);
if (page === undefined || rows === undefined) return <DashSkeleton />;
if (!page) return <LoadError text="This page could not be loaded." />;
const url = `${SITE_URL}/${handle}`;
const adminUrl = key ? `${SITE_URL}/e/${handle}#k=${key}` : null;
const tz = page.timezone;
const dismissNew = () => { localStorage.removeItem(`book:new:${handle}`); setIsNew(false); };
const markFeedUsed = () => { localStorage.setItem(`book:feed:${handle}`, "1"); setTimeout(() => setFeedUsed(true), 400); };
const list = { tz, page, meetingUrl, handle, byId, movedFrom };
return (
<div className="dash">
<header className="dash-head">
<div className="wrap dash-head-inner">
<div className="dash-title-block">
<p className="eyebrow">Your booking page</p>
<h1 className="dash-title">{page.title}</h1>
<p className="dash-meta">{page.name}, {page.durationMin} minutes, {page.meeting}</p>
</div>
<div className="dash-link">
<a className="dash-url" href={url} target="_blank" rel="noopener noreferrer">{SITE_HOST}/{handle}</a>
<span className="row dash-link-actions">
<button className="btn btn-sm" type="button" onClick={() => copy(url, "Link copied. Send it to anyone who should book time.")}>Copy link</button>
<a className="btn btn-sm btn-line" href={url} target="_blank" rel="noopener noreferrer">Open page</a>
</span>
</div>
</div>
<div className="wrap">
<div className="tabs" role="tablist" aria-label="Page">
<button role="tab" id="tab-bookings" aria-controls="panel" aria-selected={tab === "bookings"} className={tab === "bookings" ? "on" : ""} type="button" onClick={() => setTab("bookings")}>
Bookings{upcoming.length ? <span className="tab-count" aria-label={`${upcoming.length} upcoming`}>{upcoming.length}</span> : null}
</button>
<button role="tab" id="tab-settings" aria-controls="panel" aria-selected={tab === "settings"} className={tab === "settings" ? "on" : ""} type="button" onClick={() => setTab("settings")}>Settings</button>
</div>
</div>
</header>
<div className="wrap"><div className="dash-body" id="panel" role="tabpanel" aria-labelledby={`tab-${tab}`}>
{isNew && tab === "bookings" && (
<div className="notice notice-ok invite enter">
<div className="invite-text">
<strong>Your booking page is live.</strong> Share the link with anyone who should book time with you.
{adminUrl && <> Keep your admin link to see bookings on another device.</>}
</div>
<span className="row invite-actions">
<button className="btn btn-sm" type="button" onClick={() => copy(url, "Link copied")}>Copy link</button>
{adminUrl && <button className="btn btn-sm btn-line" type="button" onClick={() => copy(adminUrl, "Admin link copied. Keep it private.")}>Copy admin link</button>}
</span>
<button className="btn btn-quiet btn-icon btn-sm invite-close" type="button" aria-label="Dismiss" onClick={dismissNew}><CloseIcon /></button>
</div>
)}
{claimed && <p className="notice notice-ok">You can manage this page on this device now.</p>}
{tab === "bookings" ? (
<div className="stack enter" key="bookings">
{feedUrl && !feedUsed && (
<div className="feed-cta">
<div>
<p className="feed-title">Put every booking in your calendar</p>
<p className="muted">Book sends no emails. Subscribe once and new bookings show up in your calendar app.</p>
</div>
<FeedMenu url={feedUrl} onUse={markFeedUsed} end />
<button className="btn btn-quiet btn-icon btn-sm feed-close" type="button" aria-label="Hide this. The calendar feed stays in Settings." onClick={() => { localStorage.setItem(`book:feed:${handle}`, "1"); setFeedUsed(true); }}><CloseIcon /></button>
</div>
)}
<p className="tz-note">Times in {zoneLabel(tz)}, your page's time zone.</p>
{upcoming.length ? (
<BookingList rows={upcoming} {...list} onCancel={setCancelling} />
) : (
<div className="empty-state">
<img className="art" src="/art/card-book.webp" alt="" width="180" height="145" />
<h3>No upcoming bookings</h3>
<p>Share your link. When someone books, it shows up here right away.</p>
<button className="btn" type="button" onClick={() => copy(url, "Link copied")}>Copy your link</button>
</div>
)}
{past.length > 0 && (
<details className="fold">
<summary>Past bookings, last {PAST_DAYS} days ({past.length})</summary>
<BookingList rows={past} {...list} />
</details>
)}
{cancelled.length > 0 && (
<details className="fold">
<summary>Cancelled and moved ({cancelled.length})</summary>
<BookingList rows={cancelled} {...list} />
</details>
)}
<p className="hint">Book does not read your calendar. To block time, add days off or change your hours in Settings.</p>
</div>
) : meetingUrl === null ? (
<span className="skeleton sk-row" />
) : (
<div className="settings enter" key="settings">
<PageForm key={`${page.title}|${meetingUrl}`} mode="edit" handle={handle} page={page} meetingUrl={meetingUrl} />
<aside className="stack">
{feedUrl && (
<div className="admin-box">
<h3 className="h3">Calendar feed</h3>
<p className="muted">Subscribe in your calendar app and every booking on this page shows up there. Calendar apps check for new bookings on their own schedule, so Google Calendar can take several hours.</p>
<FeedMenu url={feedUrl} onUse={markFeedUsed} />
</div>
)}
<div className="admin-box">
<h3 className="h3">Admin link</h3>
{adminUrl ? (
<>
<p className="muted">Open it on another device to see bookings and change this page there. Anyone with it can do the same, so keep it private.</p>
<div className="link-row"><code className="link-code">{adminUrl}</code><button className="btn btn-sm btn-line" type="button" onClick={() => copy(adminUrl, "Admin link copied. Keep it private.")}>Copy</button></div>
</>
) : <p className="muted">This device can manage the page, but its admin link was made on another device.</p>}
</div>
</aside>
</div>
)}
</div></div>
{cancelling && <OwnerCancel handle={handle} booking={cancelling} page={page} onClose={() => setCancelling(null)} />}
</div>
);
}
type ListProps = { rows: Row[]; tz: string; page: Page; meetingUrl: string | null; handle: string; byId: Map<string, Row>; movedFrom: Map<string, Row>; onCancel?: (b: Row) => void };
function BookingList({ rows, tz, page, meetingUrl, handle, byId, movedFrom, onCancel }: ListProps) {
const days: [string, Row[]][] = [];
for (const b of rows) {
const d = wallClock(b.start, tz).date;
const last = days[days.length - 1];
if (last && last[0] === d) last[1].push(b); else days.push([d, [b]]);
}
const when = (b: Row) => `${formatDay(b.start, tz, undefined, "short")}, ${formatTime(b.start, tz)}`;
return (
<div className="bk-days">
{days.map(([d, list]) => (
<section key={d}>
<h3 className="day-label">{formatDay(list[0].start, tz)}</h3>
<ul className="bk-list">
{list.map((b, i) => {
const answers = parseAnswers(b.answers);
const event = meetingEvent(b.id, page, b, meetingUrl || "", `${SITE_URL}/e/${handle}`, true);
const to = b.movedTo ? byId.get(b.movedTo) : undefined;
const from = movedFrom.get(b.id);
return (
<li key={b.id} className={`bk enter${b.status === "cancelled" ? " bk-off" : ""}`} style={{ "--i": i } as React.CSSProperties}>
<div className="bk-time">
<strong>{formatTime(b.start, tz)}</strong>
<span>to {formatTime(b.end, tz)}</span>
</div>
<div className="bk-who">
<p><strong>{b.name}</strong> <a href={`mailto:${b.email}`}>{b.email}</a></p>
{b.status === "cancelled" && (
<p className="bk-status">{b.movedTo ? `Moved by the guest${to ? ` to ${when(to)}` : ""}` : `Cancelled by ${b.cancelledBy === "owner" ? "you" : "the guest"}`}</p>
)}
{from && b.status === "booked" && <p className="bk-moved">Moved from {when(from)}</p>}
{answers.map((a, j) => <p key={j} className="bk-answer"><span>{a.q}</span> {a.a}</p>)}
{b.note && <p className="bk-answer"><span>Note</span> {b.note}</p>}
</div>
{onCancel && (
<div className="bk-actions">
<button className="btn btn-sm btn-line" type="button" disabled={!meetingUrl} onClick={() => download(`${fileName(event.title)}.ics`, icsFile(event), "text/calendar")}>Add to calendar</button>
<button className="btn btn-sm btn-quiet btn-danger" type="button" onClick={() => onCancel(b)}>Cancel</button>
</div>
)}
</li>
);
})}
</ul>
</section>
))}
</div>
);
}
function OwnerCancel({ handle, booking, page, onClose }: { handle: string; booking: Row; page: Page; onClose: () => void }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const when = `${formatDay(booking.start, page.timezone, undefined, "short")} at ${formatTime(booking.start, page.timezone)}`;
const mail = `mailto:${booking.email}?subject=${encodeURIComponent(`Cancelled: ${page.title} on ${when}`)}`;
const cancel = async (close: () => void) => {
setBusy(true);
try {
await setMany([
{ path: `pages/${handle}/bookings/${booking.id}`, document: { status: "cancelled", cancelledBy: "owner", cancelledAt: serverTimestamp() } },
...cellsFor(booking.start, booking.heldUntil).map((c) => ({ path: `pages/${handle}/slots/${c}`, document: null })),
]);
toast("Booking cancelled. The time is open 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>{booking.name}'s booking on {when} is cancelled and the time opens up again. {booking.name} sees it as cancelled on their booking link.</p>
<p className="muted">Book sends no emails, so tell {booking.name} yourself: <a href={mail}>email {booking.email}</a>.</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