# Plain JavaScript

A complete clndr.pro booking form in one HTML file — fetch, a publishable key and the REST API, no framework and no build step.

No React, no bundler, no SDK: one HTML file that loads a booking page, shows the open times for a day, asks the host's questions and books. Use it on a static site, inside a CMS that accepts custom HTML, or as a reference for porting the flow to Vue, Svelte or anything else.

If you'd rather not write the form at all, the [embed script](/embed/script) drops clndr.pro's own booking page onto any site with two lines.

## The file

Save this as `book.html`, put your publishable key and slug in the three constants at the top of the script, and open it in a browser. It talks to the API directly; there's no server of yours involved.

```html title="book.html"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Book a call</title>
    <style>
      body { font: 16px/1.5 system-ui, sans-serif; margin: 0; color: #18181b; }
      main { max-width: 34rem; margin: 3rem auto; padding: 0 1rem; display: grid; gap: 1rem; }
      h1 { margin: 0; font-size: 1.5rem; }
      .muted { color: #71717a; font-size: 0.875rem; margin: 0; }
      .description { white-space: pre-line; }
      label { display: grid; gap: 0.25rem; font-size: 0.875rem; font-weight: 500; }
      input, select, textarea { font: inherit; padding: 0.5rem 0.75rem; border: 1px solid #d4d4d8; border-radius: 8px; }
      #slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); gap: 0.5rem; }
      #slots button, form button { font: inherit; padding: 0.5rem; border: 1px solid #d4d4d8; border-radius: 8px; background: #fff; cursor: pointer; }
      #slots button[aria-pressed="true"] { border-color: #2563eb; color: #2563eb; }
      form { display: grid; gap: 0.75rem; }
      form button[type="submit"] { background: #18181b; color: #fff; border-color: #18181b; }
      .check { display: flex; gap: 0.5rem; align-items: center; }
      #status:empty { display: none; }
    </style>
  </head>
  <body>
    <main>
      <div>
        <h1 id="title">Loading…</h1>
        <p class="muted" id="meta"></p>
      </div>
      <p class="description" id="description"></p>

      <label>
        Day
        <input id="day" type="date" disabled />
      </label>
      <div id="slots" role="group" aria-label="Open times"></div>

      <form id="details" hidden>
        <p class="muted" id="picked"></p>
        <label>Name <input name="name" required autocomplete="name" /></label>
        <label>Email <input name="email" type="email" required autocomplete="email" /></label>
        <div id="questions"></div>
        <button type="submit">Book</button>
      </form>

      <p id="status" role="status"></p>
    </main>

    <script type="module">
      const API = 'https://www.clndr.pro/api/v1'; // always www
      const KEY = 'clndr_pk_...'; // a publishable key: fine in page source
      const SLUG = 'intro-call';

      const $ = (id) => document.getElementById(id);
      const time = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' });
      const longDate = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' });

      async function api(path, body) {
        const res = await fetch(API + path, {
          method: body ? 'POST' : 'GET',
          headers: { Authorization: `Bearer ${KEY}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
          body: body ? JSON.stringify(body) : undefined,
        });
        const json = await res.json().catch(() => ({}));
        if (!res.ok) throw Object.assign(new Error(json.error ?? `HTTP ${res.status}`), { status: res.status });
        return json.data;
      }

      const ymd = (d) =>
        `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;

      let page; // { bookingPage, userProfile, questions }
      let picked = null; // { start, end }

      // 1. The booking page: title, host, description, questions.
      try {
        page = await api(`/booking-pages/${encodeURIComponent(SLUG)}`);
      } catch (err) {
        $('title').textContent = 'This booking page isn’t available';
        $('meta').textContent = err.message;
        throw err;
      }

      const { bookingPage, userProfile, questions } = page;
      document.title = bookingPage.title;
      $('title').textContent = bookingPage.title;
      $('meta').textContent = `${userProfile.full_name ?? userProfile.username} · ${bookingPage.duration_minutes} min`;
      // Markdown from the host: shown as text. Use a Markdown library to render it.
      $('description').textContent = bookingPage.description ?? '';

      const today = new Date();
      $('day').min = ymd(today);
      if (bookingPage.max_days_ahead) {
        $('day').max = ymd(new Date(today.getFullYear(), today.getMonth(), today.getDate() + bookingPage.max_days_ahead));
      }
      $('day').disabled = false;

      // 2. The host's questions, built with DOM APIs (never innerHTML with host text).
      for (const q of questions) {
        const label = document.createElement('label');
        let field;
        if (q.question_type === 'textarea') {
          field = document.createElement('textarea');
          field.rows = 4;
        } else if (q.question_type === 'select') {
          field = document.createElement('select');
          field.append(new Option('Choose one', ''), ...(q.options ?? []).map((o) => new Option(o, o)));
        } else {
          field = document.createElement('input');
          field.type = { email: 'email', phone: 'tel', checkbox: 'checkbox' }[q.question_type] ?? 'text';
          if (q.question_type === 'checkbox') label.className = 'check';
        }
        field.name = `q:${q.id}`;
        field.required = !!q.is_required;
        label.append(`${q.question_text}${q.is_required ? ' *' : ''}`, field);
        $('questions').append(label);
      }

      // 3. Open times for the picked day. The API reads YYYY-MM-DD as the host's day.
      async function loadSlots() {
        picked = null;
        $('details').hidden = true;
        $('slots').replaceChildren();
        if (!$('day').value) return;
        $('status').textContent = 'Loading times…';
        try {
          const slots = await api(`/booking-pages/${encodeURIComponent(SLUG)}/slots?date=${$('day').value}`);
          $('status').textContent = slots.length ? '' : 'No open times that day. Try another one.';
          for (const slot of slots) {
            const button = document.createElement('button');
            button.type = 'button';
            button.textContent = time.format(new Date(slot.start)); // the guest's local time
            button.setAttribute('aria-pressed', 'false');
            button.addEventListener('click', () => pick(slot, button));
            $('slots').append(button);
          }
        } catch (err) {
          $('status').textContent = err.message;
        }
      }

      function pick(slot, button) {
        picked = slot;
        for (const b of $('slots').children) b.setAttribute('aria-pressed', String(b === button));
        $('picked').textContent = `${longDate.format(new Date(slot.start))}, ${time.format(new Date(slot.start))}`;
        $('details').hidden = false;
      }

      $('day').addEventListener('change', loadSlots);

      // 4. Book it.
      $('details').addEventListener('submit', async (event) => {
        event.preventDefault();
        if (!picked) return;
        const form = new FormData(event.currentTarget);
        const responses = questions
          .map((q) => {
            const answer =
              q.question_type === 'checkbox' ? (form.get(`q:${q.id}`) ? 'Yes' : 'No') : String(form.get(`q:${q.id}`) ?? '').trim();
            return { questionId: q.id, answer };
          })
          .filter((r) => r.answer !== '');

        const submit = event.currentTarget.querySelector('[type="submit"]');
        submit.disabled = true;
        $('status').textContent = 'Booking…';
        try {
          const booking = await api('/bookings', {
            bookingPageId: bookingPage.id,
            guestName: String(form.get('name')),
            guestEmail: String(form.get('email')),
            startTime: picked.start,
            endTime: picked.end,
            responses,
          });
          $('details').hidden = true;
          $('slots').replaceChildren();
          $('status').textContent =
            booking.status === 'pending'
              ? `Request sent. ${userProfile.full_name ?? userProfile.username} will confirm by email.`
              : `You're booked. A confirmation is on its way to ${booking.guest_email}.`;
        } catch (err) {
          if (/no longer available|not available/i.test(err.message) || err.status === 409) {
            await loadSlots();
            $('status').textContent = 'Someone booked that time a moment ago. Pick another one.';
          } else {
            $('status').textContent = err.message;
          }
        } finally {
          submit.disabled = false;
        }
      });
    </script>
  </body>
</html>
```

## How it works

**Requests.** `api()` sends the key as `Authorization: Bearer clndr_pk_…` and unwraps the `data` field every successful response has. On failure it throws an `Error` carrying the API's own `error` message and the HTTP `status`. The API sends `Access-Control-Allow-Origin: *`, so this works from any origin, `file://` pages included.

**The day.** `<input type="date">` gives `YYYY-MM-DD`, and the API reads that as a calendar day in the **host's** timezone. Pass the string as it is. The times that come back are UTC timestamps, and `Intl.DateTimeFormat` with an `undefined` locale formats them in the guest's own locale and timezone. Today's past times and days beyond the page's booking window come back empty, and the `min`/`max` on the input keep guests inside that window.

**Questions.** Each `question_type` gets the closest native input: `textarea`, a `select` built from `options`, a `checkbox` (answered "Yes" or "No"), `tel` for `phone`, `email`, or text. Required questions use the browser's `required` validation, which matters: the API accepts bookings with unanswered questions. Every piece of text from the host goes in through `textContent`, never `innerHTML`.

**Booking.** The `POST /bookings` body uses camelCase (`bookingPageId`, `guestEmail`, `startTime`) while responses use snake_case (`guest_email`). `startTime` and `endTime` are the slot's `start` and `end`, untouched. The response's `status` is `confirmed` for *direct* pages and `pending` for *approval* pages, and the message changes to match.

**A taken slot.** If another guest booked the same time first, the API answers `409`. With a publishable key the message is usually "This time slot is not available. Fetch the slots again and pick another." The script treats any `409` as a taken slot, reloads that day's times and asks the guest to pick again.

## Making it yours

* **Styling.** The `<style>` block is the whole design; replace it with your site's.
* **The description** is Markdown, shown here as plain text with line breaks kept. To render it, run it through a Markdown library such as `marked` plus a sanitiser such as DOMPurify, since the host's text then becomes HTML.
* **Several booking pages.** `GET /booking-pages` lists every page on the key's account, including inactive and private ones. Filter on `is_active` and `visibility === 'public'` before offering them. See [List booking pages](/api-reference/operations/listBookingPages).
* **Rate limits.** A publishable key gets 120 requests a minute, shared by every visitor using it. Each guest costs about three to five: the page, a slot list per day they look at, the booking. A page with heavy traffic should load the booking page once on your server and cache it.

## Keep the key publishable

This file ships the key to every visitor, which is what publishable keys are for: they can read your pages and slots and create bookings, nothing else. Never put a secret `clndr_sk_` key in a page like this. Anything that lists, confirms or cancels bookings belongs on a server; see the [TypeScript SDK](/sdk).

**Port the plain-JavaScript booking form**

```text
Add a clndr.pro booking form to this site without React, following https://docs.clndr.pro/tutorials/vanilla-js.md (read it first; the API reference is https://docs.clndr.pro/api-reference).

- Use fetch against https://www.clndr.pro/api/v1 (always www) with a publishable key (clndr_pk_...) in an Authorization: Bearer header. Never use a secret key in browser code.
- Load GET /booking-pages/{slug}, GET /booking-pages/{slug}/slots?date=YYYY-MM-DD (the host's calendar day), then POST /bookings with bookingPageId, guestName, guestEmail, startTime and endTime copied from the slot, and responses as [{ questionId, answer }].
- Insert host-written text with textContent, never innerHTML. Show times with Intl.DateTimeFormat in the guest's timezone.
- On a 409 (the slot was taken or isn't open any more), reload that day's slots and ask the guest to pick again. Show "Request sent" instead of "booked" when the booking comes back pending.
- Match the site's existing markup and styles. Ask me for the booking page slug and which page this goes on, and leave a clearly marked placeholder for the publishable key.
```
