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