# Widget reference

Two dependency-free custom elements turn any HTML page into a booking page:

- **`<nise-booking>`** — the booking flow (service → time → details → payment → done).
- **`<nise-events>`** — the event/ticket shop.

Both render into an open shadow DOM, read their configuration from `data-*` attributes, and talk to
the [public API](04-public-api.md) directly from the browser. There is no iframe, no build step and no
npm package.

## Contents

- [Embedding](#embedding)
- [Language](#language)
- [The API base URL](#the-api-base-url)
- [`<nise-booking>` attributes](#nise-booking-attributes)
- [Booking flow step by step](#booking-flow-step-by-step)
- [Package mode](#package-mode)
- [Recurrence, cart and chain in the widget](#recurrence-cart-and-chain-in-the-widget)
- [Waitlist and occupancy](#waitlist-and-occupancy)
- [Payment step and return handling](#payment-step-and-return-handling)
- [`<nise-events>`](#nise-events)
- [Styling](#styling)
- [Accessibility](#accessibility)
- [WordPress](#wordpress)
- [Limits, caching and versioning](#limits-caching-and-versioning)
- [What the widget cannot do](#what-the-widget-cannot-do)

## Embedding

```html
<script src="https://app.nisebookings.com/widget.js" defer></script>
<nise-booking data-key="YOUR_PUBLIC_KEY"></nise-booking>
```

```html
<script src="https://app.nisebookings.com/events.js" defer></script>
<nise-events data-key="YOUR_PUBLIC_KEY"></nise-events>
```

- The scripts are classic scripts (not modules), need no dependencies, and register the element only
  if it is not registered yet — loading the script twice is harmless.
- Keep `defer`: the elements upgrade themselves and start loading as soon as they are connected.
- One element instance per page slot. The element has no JS API: no constructor, no methods, no events
  (details below); the only global it sets is `window.nisebookings.version`.
- The widget needs no cookies and sends no credentials.

## Language

The customer-facing texts ship in **Dutch and English**. Each element resolves its language on its
own, in this order:

1. **`data-lang` on the element** — trimmed and lowercased; anything starting with `nl` is Dutch,
   everything else English. Use it to run one page, or one embed, in another language than the
   account.
2. **The account's language** — **Instellingen → Algemeen → Taal van het boekingsformulier**
   (Nederlands/Engels; `locale` in `PATCH /api/v1/settings`). It arrives with `GET services`.
3. **Dutch**, when neither is set.

`<nise-booking data-key="…" data-lang="en">` sets `lang` on its host element (the shadow tree
inherits it) and re-renders the current step when `data-lang` changes; `<nise-events data-lang="en">`
reads the attribute on connect and again after the first fetch. On WordPress the plugin shortcodes
take the same value: `[nise_booking lang="en"]` and `[nise_events lang="en"]` (`"nl"` for Dutch);
without it they follow the account language.

Not translated on purpose: server data (service, staff and event titles and descriptions) and
numbers and dates. Formatted values follow the *account's* `locale`, so an English widget on a Dutch
account shows Dutch month names and `€ 1.234,56` — set the account language as well if you want
everything in English.

## The API base URL

The widget decides once, at script-load time, where the API lives:

1. `data-api="https://app.nisebookings.com"` if present — used **verbatim** (no trailing-slash
   normalisation);
2. otherwise the origin of the script's own `src` (`https://app.nisebookings.com/widget.js` → `https://app.nisebookings.com`);
3. otherwise the embedding page's origin.

If you load the scripts from anywhere other than `https://app.nisebookings.com`, **`data-api` is
mandatory**. Note that `document.currentScript` is
`null` for module scripts, inline scripts and dynamically injected `async` scripts; in those cases the
fallback is the page origin, so set `data-api` explicitly.

Requests without an `Origin` header work too (server-to-server), but for browser embeddings your
account's **Toegestane websites** list applies (Instellingen → Insluiten). Empty list = any site;
otherwise the API answers 403 `origin_not_allowed`.

## `<nise-booking>` attributes

| Attribute | Default | Meaning |
|---|---|---|
| `data-key` | — | **Required.** Your account's public key. |
| `data-api` | script origin | API base URL (see above). |
| `data-service` | — | Preselect a service id (from `GET services`). The widget skips the service step only when the service has fewer than 2 staff (or hides staff), no extras, capacity 1, the chain feature is off and the entry is not already in the cart. |
| `data-staff` | `any` | Preselect a staff id, or `any`. Ignored when the chosen service is not offered by that staff member (falls back to `any`). |
| `data-package` | — | Package mode for an already bought package: the token from the `/p/<token>` link. |
| `data-lang` | account language | Language of the customer-facing texts: `nl` or `en` (anything not starting with `nl` means English). See [Language](#language). |

Attributes are read on connect only — except `data-lang`, which the element observes: changing it
after the element is in the DOM switches the language (and the host's `lang` attribute) and
re-renders. No other attribute has an `attributeChangedCallback`, and re-inserting the same element
does not re-initialise it. If you need a different configuration, create a new element (or replace
the old one).

## Booking flow step by step

Steps are `0` services, `1` time, `2` details/payment, `3` done, `4` payment-status message,
`5` cart.

**Step 0 — Kies een dienst.** Service list with title, description, duration and price; packages show
"N dagen geldig"/"onbeperkt geldig". A staff `<select>` ("Medewerker", first option "Maakt niet uit")
appears only when the service has more than one public staff member and the business has not hidden
staff for collaborative services. "Aantal personen" appears when capacity > 1. Extras render as
checkboxes (single-choice) or number inputs; mandatory extras are pre-set and disabled. Buttons:
**Kies een tijd** (packages: **Verder**), **Dienst toevoegen** (chain), **Deze dienst weglaten** /
**Geen dienst toevoegen**, **Terug naar je afspraken** (cart). Empty: "Er zijn nu geen diensten te
boeken."

**Step 1 — Kies een tijd.** One week at a time with **Vorige week** / **Volgende week**
(`hasMore` from the API decides whether "next" is enabled). Slots are grouped per day with
localised headings. Occupancy is shown only when *Bezetting tonen* is on and capacity > 1: `3/8`
("Bezette plaatsen") or `nog 5` ("Vrije plaatsen"). Waiting-list slots are greyed with
"wachtlijst (N)". Loading text "Tijden laden…", empty "Geen vrije tijden in deze week. Kijk een week
verder.", failure "De tijden kunnen nu niet worden geladen. Probeer het opnieuw."

**Step 5 — Je afspraken** (cart; shown when *winkelmand* is on or the entry is recurring). Lists each
entry with service, extras, day/time, staff, "· herhaald" for series, and per entry **Wijzigen**,
**Verwijderen** and, when the service allows repeats, **Herhalen** / **Herhaling aanpassen**. Footer:
**Verder** and **Nog een afspraak**.

**Step 2 — Je gegevens.** Fields Naam, E-mail, Telefoon, Opmerking, then the service's own intake
questions (Instellingen → Diensten → Velden: text, textarea, choice, checkbox, number, date or file —
JPG/PNG/WebP/PDF up to 5 MB, uploaded before the booking is sent; a required one is marked, a
per-person one renders a control per person as "Persoon 1", "Persoon 2", and a conditional one
(**Toon alleen als**) appears and disappears as the customer answers the question it depends on —
see the public API's
`fields` and `{{fields}}` in mails); then, when something has to be
paid: the coupon field (**Toepassen**), payment method radios (**Ter plaatse betalen** / **Online
betalen (iDEAL, creditcard en meer)** — only when the business offers both), and, for Mollie orders where
any service has a deposit other than 100%, the choice **Alleen de aanbetaling** / **Het hele bedrag**.
A live price summary (refreshed through `POST quote`, 250 ms debounce) shows Totaal, plus Subtotaal
and Korting only when a coupon applies, a VAT line ("waarvan btw" when prices include VAT, "Btw"
otherwise) only when VAT is non-zero, "Nu te betalen" / "Later te betalen" for deposits and "Ter
plaatse te betalen (herhaalde afspraken)" when part of a series is paid on site.
Submit: **Afspraak bevestigen**, or **Naar betalen** for an online payment. Notes appear for waiting
list ("Je komt op de wachtlijst, je betaalt nu niets.") and package bookings ("Gebruik het
e-mailadres waarmee je het pakket hebt gekocht.").

**Step 3 — done.** Heading and text follow the result: "Je afspraak staat vast" when every timed item
is `approved`, "Je aanvraag is ontvangen" when something is still pending, "Je staat op de wachtlijst"
for waiting-list-only orders, "Je pakket is besteld" for package purchases.
**Nog een afspraak maken** restarts (in package mode it reloads the package).

**Error handling** is inline and specific:

| Situation | Message |
|---|---|
| Slot taken between choosing and submitting (409) | "Een gekozen tijdstip is net geboekt. Kies een ander tijdstip." — the entry is highlighted, or reopened for editing when it was the only one. |
| Validation (422) | Per-field messages ("Vul je naam in.", "Vul een geldig e-mailadres in.", "Vul een geldig telefoonnummer in.", "Je opmerking is te lang.") + "Controleer de gemarkeerde velden."; focus moves to the first invalid field. |
| Invalid coupon | "Deze code is ongeldig of al gebruikt." / "Deze code is verlopen." — the field is cleared and the price re-quoted. |
| Package errors | "Dit pakket hoort bij een ander e-mailadres.", "Dit pakket is verlopen.", "Dit pakket is opgebruikt." |
| Waiting list full | "De wachtlijst voor dit tijdstip is vol. Kies een ander tijdstip." |
| Mollie unavailable (502) | "Online betalen lukt nu even niet. Kies ter plaatse betalen of probeer het later opnieuw." |
| Anything else | "Er ging iets mis bij het boeken. Probeer het opnieuw." |
| Services unreachable | "De agenda kan nu niet worden geladen. Probeer het later opnieuw." |

## Package mode

`<nise-booking data-key="…" data-package="<token>">` (this is what `/p/<token>` renders) loads
`GET packages/<token>` and locks the widget to that package:

- The service list is replaced by the package's service with the package's fixed staff (or free
  choice), price 0, and the widget jumps straight to the time step.
- A banner shows "`<packageTitle>` · nog `N` van `M` · geldig t/m `date`".
- Cart and chain are disabled inside package mode; the API refuses paid booking of package visits.
- Invalid or used-up packages render "Dit pakket is opgebruikt of verlopen." / "Dit pakket is niet
  gevonden."

## Recurrence, cart and chain in the widget

- **Herhalen** (visible per entry when the service offers frequencies): choose *Hoe vaak* (Elke dag /
  Elke week / Om de week / Elke maand), the interval for daily ("Om de hoeveel dagen"), weekdays for
  weekly ("Op", ma–zo), a monthly option (a day number, "de Ne <weekdag>", or "de laatste <weekdag>"),
  and either **Aantal keer** (2–52) or **Of tot en met** (a date). **Data zoeken** calls
  `POST recurrence` and lists every occurrence; moved ones show "· ander tijdstip", ones without a time
  show "· geen tijd gevonden", each removable. The whole series is sent as one order with a series key.
- **Cart**: with *winkelmand* on, selections accumulate and are sent as one order; earlier entries are
  passed as `hold` during slot searches so the customer never sees their own choices as free.
- **Chain**: "Dienst toevoegen" adds a second service that must run right after the first; the time
  step then searches chain slots (`POST slots` with multiple items) and shows each part's time.

## Waitlist and occupancy

Waiting-list slots appear only when the business enabled the waiting list and the service allows it. A
slot row shows "wachtlijst (N)" where N is the number of people already waiting. Booking one creates a
`waitlisted` booking without payment; when a seat frees up the customer receives the `waitlist_offer`
mail with a first-come-first-served `/w/<token>` link.

## Payment step and return handling

For an online (Mollie) payment the widget redirects to the returned `checkoutUrl`. On return, with
`nb_pay=<token>` in the URL, it removes that parameter (`history.replaceState`), shows "Betaling
controleren…" and polls `GET payments/<token>` every 2 seconds, up to 15 times:

- **completed** → the done step ("Je betaling is ontvangen.").
- **failed/refused** → "Betaling niet gelukt" / "De betaling is niet gelukt. Je afspraak is niet
  geboekt." with **Opnieuw proberen** (restarts the flow at the service step and clears the cart).
- **still pending after 30 s** → "Betaling wordt verwerkt" / "Je betaling wordt nog verwerkt. Je krijgt
  een bevestiging per e-mail."

You do not need a thank-you page of your own when you use the widget: pass a `returnUrl` on your page
and the widget handles the return. When you build a custom checkout, do the same polling yourself —
see [`04-public-api.md`](04-public-api.md#2-mollie-checkout).

## `<nise-events>`

Attributes: `data-key`, `data-api` and `data-lang` (read on connect and again after the first fetch;
see [Language](#language)).

- **List**: each published event shows title, date range (localised, business time zone), optional image,
  info, and a row per ticket type with price ("gratis" when 0) and "nog N" or "uitverkocht"; quantity
  inputs are capped at `min(left, 50)`. **Tickets kiezen** continues; without a selection: "Kies ten
  minste één ticket."
- **Form**: Naam, E-mail, Telefoon, and when the total is > 0 and both gateways are enabled, the same
  **Betaalwijze** radios. **Tickets bestellen** sends `POST events/<id>/tickets`.
- **Done**: "Je tickets zijn besteld" with the codes, or "Je codes krijg je na betaling." when the
  order is not approved yet.
- **Payment return**: the same 2-second polling, but it requires both `nb_pay` **and** `ne` in the URL
  (`ne=1` is what this widget writes into its own `returnUrl`, so a booking widget on the same page
  does not pick up the ticket payment).
- No coupon, no notes, no deposit choice, no per-item price breakdown.
- Errors: "Er zijn niet genoeg tickets meer. Kies opnieuw." (sold out), "De verkoop voor dit
  evenement is gesloten.", "Online betalen lukt nu even niet. Kies ter plaatse betalen of probeer het
  later opnieuw.", "Er ging iets mis bij het bestellen. Probeer het opnieuw."

## Styling

The shadow DOM isolates internal styles; the host exposes design tokens as CSS custom properties.
Set them on the element (or any ancestor):

```css
nise-booking, nise-events {
  --nb-accent: #E5006F;        /* buttons, links, highlights */
  --nb-accent-hover: #C2005E;
  --nb-text: #1A1A1F;
  --nb-text-2: #4A4B68;
  --nb-text-3: #6E6F88;        /* muted meta text */
  --nb-bg: #fff;
  --nb-sunken: #F7F8FB;        /* cards, panels */
  --nb-border: #E6E7EE;
  --nb-border-strong: #C7C6D1;
  --nb-danger: #CF2E36;
  --nb-focus: #FF4D9B;         /* focus ring */
  --nb-radius: 4px;
}
```

- `font` is inherited from the page (the host sets only `font-weight: 300` and `line-height: 1.6`),
  so your typography reaches the widget; sizes and colours do not.
- The host is `display: block; max-width: 640px`. Control the width with a wrapper.
- There are **no** `::part` hooks and no class API — the internal class names are implementation
  detail. Anything not listed above cannot be restyled.
- Customer-facing texts are the built-in Dutch/English strings ([Language](#language)); only names,
  prices, durations and date formatting come from the API (your account's `locale`, `currency` and
  `timeZone`).

## Accessibility

- The error line is `role="alert" aria-live="assertive"`; the loading line and the price summary are
  `aria-live="polite"`.
- Headings are `h2` with `tabindex="-1"` and receive focus on each step change.
- Service choices are buttons with `aria-pressed`; the selected one is exposed as a toggle state.
- Invalid fields get `aria-invalid` plus `aria-describedby` pointing at their message, and focus moves
  to the first invalid field after a failed submit.
- Inputs are wrapped in or bound to `<label>`s; focus styling uses `:focus-visible` with a 2 px ring.
- The container is keyboard-operable end to end (links, buttons, radios, selects).

## WordPress

No plugin is needed. In the block editor, add a **Custom HTML** block to any page or post and paste the
two lines from [Embedding](#embedding) (`widget.js` + `<nise-booking>`, or `events.js` +
`<nise-events>`). The `data-service`, `data-staff` and other attributes work the same way,
`data-lang` included. The plugin's shortcodes take the language as `lang`: `[nise_booking lang="en"]`
and `[nise_events lang="en"]` (or `"nl"`; without it, the account's language). Your
WordPress user must be allowed to add scripts (administrators are, by default). Add the
WordPress site to **Toegestane websites** in the booking app, or leave that list empty.

## Limits, caching and versioning

| Limit | Value |
|---|---|
| Items per order | 60 (server), chain parts 10 (server); the widget itself adds no cap |
| Chain/search window | 31 days per slots call (the widget asks one week at a time) |
| Recurrence | 2–52 occurrences, interval 1–30 |
| Name / email | 255 characters |
| Phone | 50 characters |
| Notes | 2000 characters |
| Coupon code | the widget's field allows 50; the API accepts 40 |
| Ticket quantity | 1–50 per type, at most 20 types |
| Script sizes (budget) | `widget.js` ≤ 20 KB gzip (≈16.5 KB now), `events.js` ≤ 8 KB gzip (≈5.8 KB now); both languages ship in the same file |

- **Version marker.** The first of the two scripts that loads logs `nisebookings@0.4.0` to the browser
  console (once per page) and sets `window.nisebookings.version`. That is how you tell which build a
  page is running — a page that mixes a stale cached script with a fresh one reports whichever loaded
  first.
- **Cache-busting.** Appending a version query to the script URL (`/widget.js?v=0.4.0`) is supported:
  it forces a fresh fetch, and the file served does not depend on the query. The WordPress plugin
  adds `?ver=0.4.0` automatically. After an update a browser may otherwise briefly keep an old copy;
  a hard refresh fixes it too.
- Load both scripts from `https://app.nisebookings.com` so you always get the current version. A copy
  served from elsewhere needs `data-api` and does not update itself.

## What the widget cannot do

- Intake questions and file uploads (JPG, PNG, WebP or PDF, up to 5 MB) yes, both in step 2; customer
  accounts no.
- Only Dutch and English ([Language](#language)): no third language, no way to override individual
  strings.
- No JS API: no events fired, no methods to call, no way to drive it from your own script. If you need
  that, build on the [public API](04-public-api.md) instead.
- No rescheduling inside the widget; customers cancel or move their booking through the mail link
  (`/b/<token>`).
- Day-based services (rentals per whole day, `durationDays` ≥ 1) show a **start-date picker** instead of
  the time grid: the service line reads "N dagen · € X per dag", the customer picks a start date, and
  the summary and confirmation show the span ("woensdag 21 oktober → zaterdag 24 oktober · 3 dagen").
  Such a service cannot be combined with other items in a chain or cart (button hidden, the API also
  refuses it).
- No styling beyond the custom properties above, no `::part`, no theme attribute.
