# Astro

Two ways in, both fine: the **widget** (fastest, no booking code of your own) or your **own UI**
against the public API. This page is the Astro-specific part — the widget's behaviour is
[the widget reference](03-widget-reference.md), the API is [the public API](04-public-api.md).

Nothing about nisebookings is Astro-specific, and that is the point: the booking form is a web
component that talks to the API directly, so a fully static Astro site is enough. Server routes and
islands are optional, for when you want services rendered server-side or a webhook in your own project.

Everything below is one runnable app in [`examples/astro/`](examples/astro/README.md), verified with
Astro 7 and `@astrojs/node` 11 against a sandbox. All three pages point at
`https://app.nisebookings.com`.

## 1. A key

The public key goes in the browser — it is public by design (it reads the catalogue and creates
bookings, nothing else). Copy it from **Instellingen → Insluiten**, or get a 24-hour test account:

```bash
curl -s -X POST https://app.nisebookings.com/api/sandbox    # → { publicKey, apiKey, … }
```

```bash
# .env
NISE_KEY=<the public key>
NISE_API=https://app.nisebookings.com
NISE_WEBHOOK_SECRET=whsec_…        # only for the webhook route
```

Never put a **management** key (`nk_…`) in the browser or in a `PUBLIC_*` variable: that key can change
your whole account.

## 2. The widget on a static page

Two lines in any `.astro` file, and the page can stay `output: 'static'`:

```astro
---
// Pattern 1: the widget on a static page. This page is prerendered — there is no server code in it.
// The component talks to the API from the browser with the public key, so `output: 'static'` is enough.
import Layout from '../layouts/Layout.astro';
import { env, KEY } from '../lib/nise';

const APP = (env('NISE_API') || 'https://app.nisebookings.com').replace(/\/+$/, '');
---
<Layout title="nisebookings — Astro example">
  <h1>Bookings in an Astro site</h1>
  <p class="muted">
    This page is static HTML. The form below is the <code>&lt;nise-booking&gt;</code> web component: one
    script tag, one element, the colours from two CSS variables.
  </p>

  {!KEY && <p class="flash flash--error">Set <code>NISE_KEY</code> in <code>.env</code> first (see README).</p>}

  <script src={`${APP}/widget.js`} defer></script>
  <nise-booking data-key={KEY}></nise-booking>

  <style>
    /* The widget lives in a shadow DOM and inherits the page font. Theme it with custom properties. */
    nise-booking {
      --nb-accent: #E8276B;
      --nb-radius: 4px;
    }
  </style>

  <p class="muted">
    Next: <a href="/book">the same booking flow in your own UI</a>, rendered server-side.
  </p>
</Layout>
```

Notes:

- Astro scopes `<style>` to the component that writes it; this page styles the widget on the element
  itself, which is where the CSS custom properties belong anyway.
- The key is read at **build** time on a prerendered page, so a key change means a rebuild. Fetch it at
  runtime (or render the page on the server) if you rotate keys.
- Add the site's origin in **Toegestane websites** (Instellingen → Insluiten); otherwise the API answers
  `403 origin_not_allowed` and the widget shows an error. The sandbox accepts any origin.

## 3. Your own UI, server-rendered

`export const prerender = false` turns the page into a server route: the frontmatter fetches the
catalogue and the slots while the request is open, and the form posts back to the same page. Every
available time is its own submit button, so there is no client JavaScript at all — and it books fine by
keyboard.

```astro
---
// Pattern 2: your own UI, server-side. `prerender = false` makes this page SSR: Astro fetches the
// catalogue and the slots during the request, handles the form post, and books through the public API.
// Every slot is its own submit button, so the whole flow works with the browser's own form post —
// no client JavaScript (and it books by keyboard and screen reader too).
export const prerender = false;

import { randomUUID } from 'node:crypto';
import Layout from '../layouts/Layout.astro';
import { book, catalogue, localDate, slots, type Service, type Slot } from '../lib/nise';

const { tenant, services } = await catalogue().then((r) => r.body ?? { tenant: { name: '', timeZone: 'UTC', currency: 'EUR' }, services: [] });
const bookable = (services ?? []).filter((s: Service) => s.type === 'simple' || s.type === 'group');
const chosenId = Astro.url.searchParams.get('service') ?? bookable[0]?.id ?? '';
const chosen = bookable.find((s: Service) => s.id === chosenId);

/** The next free times, newest fetch each time — never cached, a slot you saw may be gone. */
async function loadSlots(serviceId: string): Promise<Slot[]> {
  const { body } = await slots(serviceId, localDate(tenant.timeZone));
  return (body?.days ?? [])
    .flatMap((d) => d.slots.map((s) => ({ ...s, date: d.date })) as Slot[])
    .filter((s) => s.state !== 'waitlist')
    .slice(0, 40);
}

let available: Slot[] = chosen && Astro.request.method === 'GET' ? await loadSlots(chosen.id) : [];

// The form post: book it, or explain why not — 409 means someone was faster, so re-render the slots.
let result: { kind: 'ok' | 'error'; text: string } | null = null;
if (Astro.request.method === 'POST') {
  const fd = await Astro.request.formData();
  const [start, staff] = String(fd.get('slot') ?? '').split('|');   // the button carries "start|staffId"
  // The key came with the rendered form, so a double-click or a back-button resubmit of this same form
  // replays the first answer instead of booking twice. A key made here would be new on every request.
  const idempotencyKey = String(fd.get('idem') ?? '') || randomUUID();
  const { status, body } = await book({
    item: { service: chosen?.id ?? '', staff, start, persons: 1 },
    name: String(fd.get('name') ?? ''),
    email: String(fd.get('email') ?? ''),
    notes: 'Astro example (own UI)',
  }, idempotencyKey);

  if (status === 201) {
    result = { kind: 'ok', text: `Booked: ${body?.serviceTitle ?? chosen?.title} — ${body?.start ?? start} (status ${body?.status}).` };
    if (chosen) available = await loadSlots(chosen.id);      // the time just booked is gone from this list now
  } else if (status === 409) {
    result = { kind: 'error', text: 'That time was just taken. Pick another one below.' };
    if (chosen) available = await loadSlots(chosen.id);      // re-render: the list moved under you
  } else {
    result = { kind: 'error', text: `Could not book (HTTP ${status}): ${body?.message ?? body?.error ?? 'unknown error'}` };
  }
}

const money = (n: number) => new Intl.NumberFormat('nl-NL', { style: 'currency', currency: tenant.currency || 'EUR' }).format(n);
// The list spans 14 days, so every button names its day (noon UTC keeps the calendar date in any zone).
const day = (date: string) => new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(`${date}T12:00:00Z`));
const formKey = randomUUID();   // one Idempotency-Key per rendered form
---
<Layout title="Own UI — nisebookings Astro example">
  <h1>Your own booking UI</h1>
  <p class="muted">
    Server-rendered by this Astro page: the catalogue and the times come from the public API in the
    frontmatter, and the form posts back to this same page. No client JavaScript.
  </p>

  {result && <p class:list={['flash', result.kind === 'ok' ? 'flash--ok' : 'flash--error']}>{result.text}</p>}

  <h2>Services</h2>
  <ul class="cards">
    {bookable.map((s: Service) => (
      <li class="card">
        <h2><a href={`/book?service=${s.id}`}>{s.title}</a></h2>
        <p class="muted">{s.duration} min · {money(s.price)}</p>
      </li>
    ))}
  </ul>

  {chosen && (
    <>
      <h2>{chosen.title} — pick a time</h2>
      {available.length === 0
        ? <p class="flash">No free times in the next 14 days. Try another service or your own account.</p>
        : (
          <form method="post" class="stack">
            <input type="hidden" name="service" value={chosen.id} />
            <input type="hidden" name="idem" value={formKey} />
            <label>Name <input name="name" required autocomplete="name" /></label>
            <label>E-mail <input name="email" type="email" required autocomplete="email" /></label>
            <p class="muted">Click a time to book it (your time zone: {tenant.timeZone}).</p>
            <ul class="slots">
              {available.map((s) => (
                <li>
                  <button class="button" type="submit" name="slot" value={`${s.start}|${s.staffId}`}>
                    {day(s.date)} {s.time}{s.staffName ? ` · ${s.staffName}` : ''}
                  </button>
                </li>
              ))}
            </ul>
          </form>
        )}
    </>
  )}
</Layout>
```

What this shows:

- **Prices and slots come from the server.** `GET /services` and `GET /slots` are the same calls the
  widget makes; the server recomputes the price on booking, so what you display cannot be trusted for
  the money — the API is.
- **`Idempotency-Key` on the POST.** One key per rendered form, sent along in a hidden field: a
  double-click, a retry or a back-button resubmit of that form returns the first response instead of
  booking twice. (A key made while handling the POST would be new on every request and protect nothing.)
- **409 means someone was faster.** Re-fetch the slots and let the customer pick again — never retry
  with the same `start`.
- **The time zone.** `slots?from=` wants a date in the business's time zone; the `time` field is already
  formatted for the business, the `start` is UTC — send `start` back exactly as you received it.

If you would rather not render on the server, the same calls work from the browser against the public
API (CORS is allowed for the origins in the account); the widget's own flow is the simplest example.

## 4. Webhooks in a server route

React to bookings, cancellations, tickets and payments where your code already lives:

```ts
// Pattern 3: a webhook endpoint as an Astro server route. Register this URL in Instellingen →
// Webhooks (it must be publicly reachable), copy the whsec_… secret into .env, and every booking,
// cancellation, ticket or payment lands here.
//
// Astro gives you the raw body (`request.text()`), which is what the signature is computed over —
// re-serialising parsed JSON would break it.
import type { APIRoute } from 'astro';
import { SECRET, verify } from '../../lib/nise';

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const raw = await request.text();
  const headers = Object.fromEntries(request.headers);          // lowercase keys, as the verifier expects

  if (!SECRET || !verify(SECRET, headers, raw, 300)) {
    return new Response(null, { status: 401 });                  // reject before trusting anything
  }

  const event = JSON.parse(raw) as { id: string; type: string; data?: { id?: string } };
  // Acknowledge first, work after: a slow handler is a failed delivery. Dedupe on event.id — a
  // delivery can be retried after a timeout even though you answered.
  console.log('nisebookings webhook', event.type, event.data?.id ?? '', `(event ${event.id})`);

  return new Response(null, { status: 204 });
};
```

The verifier itself is the one from [the webhooks page](10-webhooks.md), copied into
`src/lib/nise.ts` so this example has no dependencies beyond Astro. Point the endpoint at a public URL
(register it in **Instellingen → Webhooks**), answer 2xx within 10 seconds, and dedupe on `event.id`:
a delivery can be retried after a timeout even though you answered.

## 5. Pitfalls specific to Astro

- **Scoped styles.** `<style>` in a layout does not reach the page slotted into it. Use `is:global` for
  layout-level styles, or style the element in the page.
- **`import.meta.env` vs `process.env`.** `astro dev` loads `.env` into Vite's `import.meta.env`; the
  built Node server only sees real environment variables. Read both, as `src/lib/nise.ts` does, or run
  the server with `node --env-file=.env dist/server/entry.mjs`.
- **Prerendered pages bake in the public key** at build time (it is public, so that is fine — just
  rebuild when it changes).
- **View transitions are untested with the widget.** On client-side navigation the element is
  re-created; the script re-upgrades it, but we have not verified every flow. Test that path before you
  rely on it, or leave view transitions off on the booking page.
- **Do not wrap the widget in a `<form>`** — it has its own submit flow, and a nested form is invalid
  HTML.

## 6. Run the example

```bash
git clone --depth 1 https://github.com/spoetnik/nisebookings.git   # or copy examples/astro
cd examples/astro && cp .env.example .env                            # paste the key
npm install && npm run dev                                           # http://localhost:4321
```

Next steps: [notifications and webhooks](10-webhooks.md) for reacting to bookings in your backend,
[the widget reference](03-widget-reference.md) for every attribute and token, and
[the sandbox](08-sandbox.md) if you want to test without touching a real account.
