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/PageForm.tsx
// Making or editing a booking page: who you are, the meeting link, how long, the weekly hours and days off.
// Hours are wall-clock times in the page's time zone; bookers see open times in their own.
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
import { get, setMany, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { rememberPage } from "./lib/pages";
import { toast, CloseIcon, PlusIcon } from "./ui";
import { SITE_URL } from "../functions/lib/config";
import {
parseHours, stringifyHours, hasHours, hoursSummary, parseQuestions, checkMeetingUrl, meetingKind, handleProblem, suggestHandle,
formatMinutes, zoneLabel, randomId, parseDaysOff, readDaysOff, dayRangeLabel, wallClock,
WEEKDAYS, WEEKDAY_SHORT, DURATIONS, BUFFERS, DEFAULTS, DEFAULT_HOURS, LIMITS,
type DayRange, type Hours, type Page, type Question, type Weekday,
} from "../functions/lib/book";
const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
export const SITE_HOST = SITE_URL.replace(/^https?:\/\//, "");
const NOTICE = [0, 1, 2, 4, 8, 12, 24, 48, 72, 168];
const HORIZON = [7, 14, 30, 60, 90, 180, 365];
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const noticeLabel = (h: number) => (h === 0 ? "No minimum" : h % 24 === 0 ? (h === 168 ? "1 week" : plural(h / 24, "day")) : plural(h, "hour"));
const horizonLabel = (d: number) => (d === 7 ? "1 week" : d === 14 ? "2 weeks" : d === 180 ? "6 months" : d === 365 ? "1 year" : plural(d, "day"));
const withValue = (list: number[], v: number) => (list.includes(v) ? list : [...list, v].sort((a, b) => a - b));
/** Monday-first, or Sunday-first where calendars start on Sunday. */
export function weekOrder(): Weekday[] {
let first = 1;
try {
const loc = new Intl.Locale(navigator.language) as Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number } };
const info = loc.getWeekInfo?.() ?? loc.weekInfo;
first = info?.firstDay ? info.firstDay % 7 : ["US", "CA", "MX", "BR", "JP", "IL", "KR", "PH", "TW", "IN", "ZA", "HK"].includes(loc.maximize().region || "US") ? 0 : 1;
} catch { /* Monday */ }
return Array.from({ length: 7 }, (_, i) => WEEKDAYS[(first + i) % 7]);
}
/** Every time zone the browser knows, with `current` first if the list lacks it. */
export function zoneOptions(current: string): string[] {
const list = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.("timeZone") ?? [];
return list.includes(current) ? list : [current, ...list];
}
type Props = { mode: "create" } | { mode: "edit"; handle: string; page: Page; meetingUrl: string };
type Errors = Partial<Record<"name" | "meetingUrl" | "address", string>>;
export function PageForm(props: Props) {
const editing = props.mode === "edit" ? props : null;
const p = editing?.page;
// The landing's form stays mounted under the dashboard's, so ids need a per-form prefix.
const fid = useId();
const autoTitle = (d: number) => `${d} minute call`;
const [name, setName] = useState(p?.name ?? "");
const [meetingUrl, setMeetingUrl] = useState(editing?.meetingUrl ?? "");
const [durationMin, setDuration] = useState(p?.durationMin ?? DEFAULTS.durationMin);
const [title, setTitle] = useState(p && p.title !== autoTitle(p.durationMin) ? p.title : "");
const [handle, setHandle] = useState("");
const [tz, setTz] = useState(p?.timezone ?? BROWSER_TZ);
const [pickZone, setPickZone] = useState(false);
const [hours, setHours] = useState<Hours>(() => (p ? parseHours(p.hours) : DEFAULT_HOURS));
const [daysOff, setDaysOff] = useState<DayRange[]>(() => parseDaysOff(p?.daysOff));
// Dates picked but not yet added; saving adds them, so a pick is never dropped silently.
const [offDraft, setOffDraft] = useState<OffDraft | null>(null);
const [editHours, setEditHours] = useState(Boolean(editing));
const [more, setMore] = useState(Boolean(editing));
const [noticeHours, setNotice] = useState(p?.noticeHours ?? DEFAULTS.noticeHours);
const [horizonDays, setHorizon] = useState(p?.horizonDays ?? DEFAULTS.horizonDays);
const [bufferMin, setBuffer] = useState(p?.bufferMin ?? DEFAULTS.bufferMin);
const [description, setDescription] = useState(p?.description ?? "");
const [questions, setQuestions] = useState<Question[]>(() => (p ? parseQuestions(p.questions) : []));
const [taken, setTaken] = useState<Record<string, boolean>>({});
const [busy, setBusy] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [error, setError] = useState<string | null>(null);
const address = editing ? editing.handle : handle || suggestHandle(name);
const addressProblem = address ? handleProblem(address) : null;
const order = useMemo(weekOrder, []);
const today = wallClock(Date.now() / 1000, tz).date;
// Tell the owner early whether the address is free.
useEffect(() => {
if (editing || !address || addressProblem || address in taken) return;
const timer = window.setTimeout(async () => {
try {
await me();
const exists = Boolean(await get(`pages/${address}`));
setTaken((t) => ({ ...t, [address]: exists }));
} catch { /* checked again on submit */ }
}, 350);
return () => window.clearTimeout(timer);
}, [address, addressProblem]); // eslint-disable-line react-hooks/exhaustive-deps
const zones = useMemo(() => zoneOptions(tz), [tz]);
const badRange = WEEKDAYS.find((d) => hours[d].some(([a, b]) => b <= a));
const clear = (k: keyof Errors) => { if (errors[k]) setErrors({ ...errors, [k]: undefined }); };
const field = (k: keyof Errors) => ({ id: `${fid}${k}`, "aria-invalid": errors[k] ? true : undefined, "aria-describedby": errors[k] ? `${fid}${k}-error` : undefined });
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const found: Errors = {};
let url = "";
if (!name.trim()) found.name = "Type your name. People see it on your page.";
try { url = checkMeetingUrl(meetingUrl); } catch (err) { found.meetingUrl = (err as Error).message; }
if (!editing && (!address || addressProblem)) found.address = addressProblem ?? "Pick a page address.";
setErrors(found);
const first = (Object.keys(found) as (keyof Errors)[])[0];
if (first) return document.getElementById(`${fid}${first}`)?.focus();
if (badRange) return setError(`Check your ${WEEKDAY_SHORT[badRange]} hours: each range must end after it starts.`);
if (!hasHours(hours)) return setError("Open at least one day in your weekly hours.");
const cleanQuestions = questions.map((q) => ({ label: q.label.trim(), required: q.required })).filter((q) => q.label);
let off: DayRange[];
try {
off = readDaysOff([...daysOff, ...(offDraft?.from ? [draftRange(offDraft)] : [])], today);
} catch (err) {
return setError((err as Error).message.replace(/^daysOff: /, ""));
}
const fields = {
name: name.trim(), title: title.trim() || autoTitle(durationMin), description: description.trim(), durationMin, timezone: tz,
hours: stringifyHours(hours), noticeHours, horizonDays, bufferMin, questions: JSON.stringify(cleanQuestions), meeting: meetingKind(url),
// An optional field: left out when there is nothing to store, cleared with null.
...(off.length ? { daysOff: JSON.stringify(off) } : p?.daysOff ? { daysOff: null } : {}),
};
setBusy(true);
try {
if (editing) {
await setMany([
{ path: `pages/${editing.handle}`, document: fields },
...(url !== editing.meetingUrl ? [{ path: `pagelinks/${editing.handle}`, document: { meetingUrl: url } }] : []),
]);
rememberPage({ handle: editing.handle, title: fields.title });
setDaysOff(off);
setOffDraft(null);
toast("Saved. Your page shows the changes now.");
setBusy(false);
return;
}
const uid = await me();
if (await get(`pages/${address}`)) {
setTaken((t) => ({ ...t, [address]: true }));
setBusy(false);
setErrors({ address: `${SITE_HOST}/${address} is taken. Try another address.` });
return;
}
const key = randomId(24);
await setMany([
{ path: `pages/${address}`, document: { ...fields, createdBy: uid, createdAt: serverTimestamp() } },
{ path: `pages/${address}/owners/${uid}`, document: { at: serverTimestamp() } },
{ path: `pagekeys/${address}`, document: { key } },
{ path: `pagelinks/${address}`, document: { meetingUrl: url } },
]);
rememberPage({ handle: address, title: fields.title, key, at: Date.now() });
localStorage.setItem(`book:new:${address}`, "1");
navigate(`/e/${address}`);
// The landing stays mounted under the dashboard; leave the form fresh for another page.
setName(""); setMeetingUrl(""); setHandle(""); setTitle(""); setDescription(""); setQuestions([]); setDaysOff([]); setOffDraft(null); setBusy(false);
} catch (err) {
setError(explain(err));
setBusy(false);
}
};
const addressNote = !address ? "Filled in from your name. You can change it."
: addressProblem ? addressProblem
: taken[address] ? "Taken. Try another."
: taken[address] === false ? "Available." : "Checking...";
return (
<form className={`card editor${editing ? " editor-edit" : ""}`} onSubmit={submit} noValidate>
<label className="field">
<span>Your name</span>
<input className="input" value={name} onChange={(e) => { setName(e.target.value); clear("name"); }} maxLength={LIMITS.name} placeholder="Sam Lee" autoComplete="name" {...field("name")} />
{errors.name && <span className="field-error" id={`${fid}name-error`}>{errors.name}</span>}
</label>
<label className="field">
<span>Meeting link</span>
<input className="input" value={meetingUrl} onChange={(e) => { setMeetingUrl(e.target.value); clear("meetingUrl"); }} maxLength={LIMITS.url} placeholder="https://meet.google.com/abc-defg-hij" inputMode="url" autoComplete="off" spellCheck={false} {...field("meetingUrl")} />
{errors.meetingUrl ? <span className="field-error" id={`${fid}meetingUrl-error`}>{errors.meetingUrl}</span> : <small>Your Google Meet, Zoom or Teams link. People see it only after they book.</small>}
</label>
<div className="field">
<span className="label" id={`${fid}length`}>Length</span>
<div className="seg" role="radiogroup" aria-labelledby={`${fid}length`}>
{DURATIONS.map((d) => (
<button key={d} type="button" role="radio" aria-checked={durationMin === d} className={durationMin === d ? "on" : ""} onClick={() => setDuration(d)}>{d} min</button>
))}
</div>
</div>
<div className="field">
<div className="times-head">
<span className="label">Weekly hours</span>
<span className="zone-note">
{pickZone ? (
<select className="select select-inline" value={tz} onChange={(e) => setTz(e.target.value)} aria-label="Time zone" autoFocus>
{zones.map((z) => <option key={z} value={z}>{z.replace(/_/g, " ")}</option>)}
</select>
) : (
<>In {zoneLabel(tz)} <button className="link-btn" type="button" onClick={() => setPickZone(true)}>Change</button></>
)}
</span>
</div>
{editHours ? (
<HoursEditor value={hours} order={order} onChange={setHours} />
) : (
<div className="hours-summary">
<div>{hoursSummary(hours, order).map((l) => <p key={l}>{l}</p>)}</div>
<button className="btn btn-sm btn-line" type="button" onClick={() => setEditHours(true)}>Change</button>
</div>
)}
</div>
<DaysOffField value={daysOff} onChange={setDaysOff} draft={offDraft} onDraft={setOffDraft} today={today} />
{editing ? (
<div className="field">
<span className="label">Page address</span>
<p className="address-fixed">{SITE_HOST}/<strong>{editing.handle}</strong></p>
</div>
) : (
<label className="field">
<span>Page address</span>
<span className={`address${errors.address ? " address-bad" : ""}`}>
<span className="address-host">{SITE_HOST}/</span>
<input value={address} onChange={(e) => { setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")); clear("address"); }} maxLength={32} placeholder="sam-lee" autoComplete="off" autoCapitalize="none" spellCheck={false} aria-label="Page address" {...field("address")} />
</span>
{errors.address
? <span className="field-error" id={`${fid}address-error`}>{errors.address}</span>
: <small className={addressProblem || taken[address] ? "bad" : taken[address] === false ? "good" : ""} aria-live="polite">{addressNote}</small>}
</label>
)}
{more ? (
<div className="more">
<label className="field">
<span>Meeting title</span>
<input className="input" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={LIMITS.title} placeholder={autoTitle(durationMin)} autoComplete="off" />
</label>
<label className="field">
<span>Description <span className="optional">optional</span></span>
<textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} maxLength={LIMITS.description} rows={2} placeholder="What the call is for, or what to prepare" />
</label>
<div className="more-grid">
<label className="field">
<span>Minimum notice</span>
<select className="select" value={noticeHours} onChange={(e) => setNotice(Number(e.target.value))}>
{withValue(NOTICE, noticeHours).map((h) => <option key={h} value={h}>{noticeLabel(h)}</option>)}
</select>
</label>
<label className="field">
<span>Book up to</span>
<select className="select" value={horizonDays} onChange={(e) => setHorizon(Number(e.target.value))}>
{withValue(HORIZON, horizonDays).map((d) => <option key={d} value={d}>{horizonLabel(d)} ahead</option>)}
</select>
</label>
<label className="field">
<span>Break after each meeting</span>
<select className="select" value={bufferMin} onChange={(e) => setBuffer(Number(e.target.value))}>
{BUFFERS.map((b) => <option key={b} value={b}>{b ? `${b} minutes` : "None"}</option>)}
</select>
</label>
</div>
<div className="field">
<span className="label">Questions for the person booking</span>
<small>Name and email are always asked. Add up to {LIMITS.questions} more.</small>
{questions.map((q, i) => (
<div className="question enter" key={i}>
<input className="input" value={q.label} onChange={(e) => setQuestions(questions.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} maxLength={LIMITS.question} placeholder="What would you like to talk about?" aria-label={`Question ${i + 1}`} />
<label className="check check-sm"><input type="checkbox" checked={q.required} onChange={(e) => setQuestions(questions.map((x, j) => (j === i ? { ...x, required: e.target.checked } : x)))} /> Required</label>
<button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Remove question ${i + 1}`} onClick={() => setQuestions(questions.filter((_, j) => j !== i))}><CloseIcon /></button>
</div>
))}
{questions.length < LIMITS.questions && (
<button className="link-btn" type="button" onClick={() => setQuestions([...questions, { label: "", required: false }])}>+ Add a question</button>
)}
</div>
</div>
) : (
<button className="link-btn" type="button" onClick={() => setMore(true)}>+ More options: title, notice, breaks, questions</button>
)}
{error && <p className="notice notice-error" role="alert">{error}</p>}
{editing ? (
<div className="action-bar">
<button className="btn" type="submit" aria-busy={busy || undefined}>Save changes</button>
</div>
) : (
<div className="editor-submit">
<button className="btn btn-lg btn-block" type="submit" aria-busy={busy || undefined}>Create my booking page</button>
<p className="hint center">Free. No sign-up. Book does not read your calendar: you set the hours you are free.</p>
</div>
)}
</form>
);
}
type OffDraft = { from: string; to: string };
const draftRange = ({ from, to }: OffDraft): DayRange => [from, to && to >= from ? to : from];
/** Dates nobody can book, like a holiday. Ranges are whole days in the page's time zone. */
function DaysOffField({ value, onChange, draft, onDraft, today }: {
value: DayRange[]; onChange: (v: DayRange[]) => void; draft: OffDraft | null; onDraft: (d: OffDraft | null) => void; today: string;
}) {
const [error, setError] = useState<string | null>(null);
const shown = value.filter(([, b]) => b >= today);
const add = () => {
if (!draft?.from) return setError("Pick the first day off.");
try {
onChange(readDaysOff([...shown, draftRange(draft)], today));
setError(null); onDraft(null);
} catch (e) {
setError((e as Error).message.replace(/^daysOff: /, ""));
}
};
return (
<div className="field">
<span className="label">Days off</span>
{shown.length > 0 && (
<ul className="off-list">
{shown.map((r) => (
<li key={r[0]} className="enter">
<span>{dayRangeLabel(r)}</span>
<button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Remove ${dayRangeLabel(r)}`} onClick={() => onChange(shown.filter((x) => x !== r))}><CloseIcon /></button>
</li>
))}
</ul>
)}
{draft ? (
<div className="off-add enter">
<label className="field">
<span>First day</span>
<input className="input" type="date" min={today} value={draft.from} onChange={(e) => { onDraft({ ...draft, from: e.target.value }); setError(null); }} autoFocus />
</label>
<label className="field">
<span>Last day <span className="optional">optional</span></span>
<input className="input" type="date" min={draft.from || today} value={draft.to} onChange={(e) => onDraft({ ...draft, to: e.target.value })} />
</label>
<div className="row off-buttons">
<button className="btn btn-line" type="button" onClick={add}>Add</button>
<button className="btn btn-quiet" type="button" onClick={() => { onDraft(null); setError(null); }}>Cancel</button>
</div>
</div>
) : (
<button className="link-btn" type="button" onClick={() => onDraft({ from: "", to: "" })}>+ Add days off</button>
)}
{error && <span className="field-error" role="alert">{error}</span>}
</div>
);
}
const TIMES = Array.from({ length: 97 }, (_, i) => i * 15);
function TimeSelect({ value, onChange, from, to, label }: { value: number; onChange: (v: number) => void; from: number; to: number; label: string }) {
return (
<select className="select select-time" value={value} onChange={(e) => onChange(Number(e.target.value))} aria-label={label}>
{TIMES.filter((t) => t >= from && t <= to).map((t) => <option key={t} value={t}>{t === 1440 ? "Midnight" : formatMinutes(t)}</option>)}
</select>
);
}
export function HoursEditor({ value, order, onChange }: { value: Hours; order: Weekday[]; onChange: (h: Hours) => void }) {
const set = (d: Weekday, ranges: [number, number][]) => onChange({ ...value, [d]: ranges });
const open = (d: Weekday) => {
const near = order.map((x) => value[x]).find((r) => r.length);
set(d, near ? near.map(([a, b]) => [a, b] as [number, number]) : [[540, 1020]]);
};
const addRange = (d: Weekday) => {
const last = value[d][value[d].length - 1];
const start = last ? Math.min(last[1] + 60, 1380) : 540;
set(d, [...value[d], [start, Math.min(start + 120, 1440)]]);
};
return (
<div className="hours">
{order.map((d) => (
<div className="day-row" key={d}>
<label className="check day-check">
<input type="checkbox" checked={value[d].length > 0} onChange={(e) => (e.target.checked ? open(d) : set(d, []))} />
{WEEKDAY_SHORT[d]}
</label>
{value[d].length ? (
<div className="day-ranges">
{value[d].map(([a, b], i) => (
<div className={`range${b <= a ? " range-bad" : ""}`} key={i}>
<TimeSelect value={a} from={0} to={1425} label={`${WEEKDAY_SHORT[d]} from`} onChange={(v) => set(d, value[d].map((r, j) => (j === i ? [v, r[1]] : r)))} />
<span className="slot-to">to</span>
<TimeSelect value={b} from={15} to={1440} label={`${WEEKDAY_SHORT[d]} until`} onChange={(v) => set(d, value[d].map((r, j) => (j === i ? [r[0], v] : r)))} />
{i === 0 ? (
<button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Add more ${WEEKDAY_SHORT[d]} hours`} onClick={() => addRange(d)}><PlusIcon /></button>
) : (
<button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Remove these ${WEEKDAY_SHORT[d]} hours`} onClick={() => set(d, value[d].filter((_, j) => j !== i))}><CloseIcon /></button>
)}
</div>
))}
</div>
) : (
<span className="day-off">Unavailable</span>
)}
</div>
))}
</div>
);
}
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