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/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
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