book-free.openapps.xyz

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.
Owned app · rev f8f1b3a80195
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.
← All files
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
f8f1b3a801 · 2 hours ago
Quality pass: guest reschedule, days off, calendar feed, API docs
c827e5d073 · 5 hours ago
Bounded source sync
68e67b4092 · 1 day ago
Bounded source sync
ab2e006f9b · 1 day ago
Bounded source sync
bb10824b54 · 1 day ago
Bounded source sync
d22f765fb7 · 1 day ago
Bounded source sync
30b4b06513 · 1 day ago
Bounded source sync
c3d297477a · 1 day ago
Bounded source sync
7b18317417 · 1 day ago