# Next.js

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 Next.js-specific part — the behaviour of the widget itself
is [the widget reference](03-widget-reference.md), the API is [the public API](04-public-api.md).

Everything below is one runnable app in [`examples/nextjs/`](examples/nextjs/README.md) (App Router,
tested with Next 16 / React 19). Both 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.local
NEXT_PUBLIC_NISE_KEY=<the public key>
```

Never put a **management** key (`nk_…`) in a `NEXT_PUBLIC_*` variable or in client code: that key can
change your whole account.

## 2. The widget, in one client component

`app/layout.tsx` — nothing special, but this is where `lang` and the stylesheet live:

```tsx
import type { ReactNode } from 'react';
import './globals.css';

export const metadata = { title: 'Salon Vlek' };

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="nl">
      <body>
        {children}
        {/* React hoists and dedupes this; the element below upgrades itself when the script runs. */}
        <script async src="https://app.nisebookings.com/widget.js" />
      </body>
    </html>
  );
}
```

`app/booking-widget.tsx`:

```tsx
'use client';
import type { DetailedHTMLProps, HTMLAttributes } from 'react';

// React renders an unknown tag as-is, but TypeScript needs the attribute types once.
declare module 'react' {
  namespace JSX {
    interface IntrinsicElements {
      'nise-booking': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & { 'data-key': string };
    }
  }
}

/** The widget path: the script lives in app/layout.tsx; this is just the element it upgrades. */
export function BookingWidget() {
  const key = process.env.NEXT_PUBLIC_NISE_KEY;
  if (!key) return <p>Set NEXT_PUBLIC_NISE_KEY in .env.local (see 08-sandbox.md for a test key).</p>;
  return <nise-booking data-key={key} />;
}
```

Use it from any page — a server component is fine:

```tsx
import Link from 'next/link';
import { BookingWidget } from './booking-widget';

export default function Home() {
  return (
    <main>
      <h1>Salon Vlek</h1>
      <p>Knippen, kleuren en groepslessen. Boek direct online:</p>
      <BookingWidget />
      <p><Link href="/headless">Zelfde afspraak, maar met eigen UI →</Link></p>
    </main>
  );
}
```

Why it is this small:

- **A plain `<script async src=…>` in the layout.** React hoists and dedupes it, so it loads once per
  page and the element upgrades itself when it arrives — independent of whether your own components
  have hydrated yet. `next/script` is the Next-specific alternative; note that it injects the tag from
  the client runtime, so a page that has not hydrated does not load it.
- **The element is a custom element with a shadow DOM.** React renders it as an ordinary unknown tag;
  page CSS cannot reach inside it. Style it with the host's design tokens instead
  (`--nb-accent`, `--nb-radius`, … — full list in the [widget reference](03-widget-reference.md#styling)):
  set them on `:root` or on a wrapping element.
- Attributes are strings: `data-key` is required; `data-lang`, `data-service`, `data-staff`,
  `data-package` and `data-api` are optional.

## 3. Your own UI on the public API

Two components: the server fetches the catalogue, the client fetches times and books.

`app/headless/page.tsx`:

```tsx
import { BookingForm } from './booking-form';

export type Service = { id: string; title: string; duration: number; price: number };

/** Availability changes by the minute, so this page is not prerendered. */
export const dynamic = 'force-dynamic';

async function listServices(): Promise<Service[]> {
  const res = await fetch(`https://app.nisebookings.com/api/public/${process.env.NEXT_PUBLIC_NISE_KEY}/services`, { cache: 'no-store' });
  if (!res.ok) throw new Error(`services answered ${res.status}`);
  const { services } = await res.json();
  return services.map(({ id, title, duration, price }: Service) => ({ id, title, duration, price }));
}

export default async function Headless() {
  const services = await listServices();
  return (
    <main>
      <h1>Boek online</h1>
      <p>Deze pagina haalt de diensten server-side op en boekt via de publieke API — geen widget.</p>
      <BookingForm services={services} />
    </main>
  );
}
```

`app/headless/booking-form.tsx`:

```tsx
'use client';
import { useState } from 'react';
import type { Service } from './page';

type Slot = { start: string; time: string; staffId: string };

const API = 'https://app.nisebookings.com';

/** Own UI on the public API: availability first, then a booking. The server re-checks everything. */
export function BookingForm({ services }: { services: Service[] }) {
  const [service, setService] = useState(services[0]?.id ?? '');
  const [slots, setSlots] = useState<Slot[] | null>(null);
  const [message, setMessage] = useState('');
  const [form, setForm] = useState({ name: '', email: '' });

  const key = process.env.NEXT_PUBLIC_NISE_KEY;

  async function loadSlots() {
    setMessage('');
    const today = new Date().toISOString().slice(0, 10);
    const res = await fetch(`${API}/api/public/${key}/slots?service=${service}&from=${today}&days=7`);
    const body = await res.json();
    setSlots(body.days?.flatMap((d: { slots: Slot[] }) => d.slots) ?? []);
    if (!res.ok) setMessage(`Tijden laden lukte niet (${res.status}).`);
  }

  async function book(slot: Slot) {
    setMessage('Bezig…');
    const res = await fetch(`${API}/api/public/${key}/bookings`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ items: [{ service, staff: slot.staffId, start: slot.start }], ...form, phone: '', notes: '' }),
    });
    const body = await res.json();
    if (res.status === 201) return setMessage(`Geboekt: ${new Date(slot.start).toLocaleString('nl-NL')}.`);
    // 409 slot_unavailable = someone was first. Never retry the same slot; fetch fresh times.
    if (res.status === 409) { setMessage('Dat tijdslot was net bezet. Kies een nieuw tijdstip.'); return void loadSlots(); }
    const field = body.fields && Object.keys(body.fields)[0];
    setMessage(field ? `${field}: ${body.fields[field]}` : `Boeken lukte niet (${res.status}).`);
  }

  if (!services.length) return <p>Deze account heeft nog geen diensten.</p>;
  return (
    <section>
      <label>
        Dienst:{' '}
        <select value={service} onChange={(e) => { setService(e.target.value); setSlots(null); }}>
          {services.map((s) => <option key={s.id} value={s.id}>{s.title} — {s.duration} min</option>)}
        </select>
      </label>
      <p>
        <label>Naam <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>{' '}
        <label>E-mail <input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></label>
      </p>
      <p><button onClick={loadSlots}>Toon vrije tijden</button></p>
      {slots && (
        <ul>
          {slots.map((s) => (
            <li key={s.start}>
              <button onClick={() => book(s)} disabled={!form.name || !form.email}>
                {new Date(s.start).toLocaleString('nl-NL', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
              </button>
            </li>
          ))}
          {!slots.length && <li>Geen vrije tijden in de komende week.</li>}
        </ul>
      )}
      {message && <p role="status">{message}</p>}
    </section>
  );
}
```

What the code is careful about:

- **`staff: slot.staffId`** — pass back the staff member the slot was offered for; the server resolves
  `"any"` for you, but a slot belongs to one agenda.
- **`409 slot_unavailable`** — somebody else booked that time. Re-fetch, do not retry the same slot.
  `409 capacity_exceeded` means the same thing for a group.
- **Prices come from the API**, never from your own arithmetic: `POST /quote` takes the same items as
  `POST /bookings` and returns the total, including extras, day services and coupons.
- **422** field messages are per field id — see [errors](04-public-api.md#cors-and-errors); intake
  questions come with the service's `fields`.
- The booking goes **straight from the browser** to `app.nisebookings.com`. That works because every
  account reflects the request origin when **Toegestane websites** is empty (default) or contains your
  site; otherwise the API answers `403 origin_not_allowed`. Proxying through a route handler is
  possible but not necessary.

## 4. Pitfalls specific to Next.js

- Do not wrap the widget in a `<form>` — it has its own submit flow, and a nested form is invalid HTML.
- Do not render `<nise-booking>` inside `dangerouslySetInnerHTML`: the widget upgrades elements it
  finds when the script loads, and React must own the node.
- A dynamic import (`next/dynamic` with `ssr: false`) is not needed; the element is inert until the
  script runs.
- Don't cache JSON responses of `slots` (`cache: 'no-store'` or `revalidate: 0`): stale availability
  produces 409s.

## 5. Run the example

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

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.
