@v-office/website-headless 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,85 +1,2208 @@
1
1
  # @v-office/website-headless
2
2
 
3
- The **behavior layer** for v-office websites. The invariant: **this package
4
- never contains UI.** It is `.ts` only. Imported UI lives one layer up in
5
- `@v-office/website-components` and renders over the hooks here; the
6
- structural requirements that UI must satisfy (`checkout/structure.ts`) live
7
- HERE, so a site's own UI is held to the same contract.
3
+ The **behaviour layer** of v-office websites: stores, pure rules, SDK runners, models, constants and
4
+ the part vocabularies that something other than styling depends on. It never renders. A site uses it
5
+ through `@v-office/website-components` (React surfaces over these stores) or builds its own UI over
6
+ the same stores and is held to the same rules: the quote, checkout and contact flows, the insurance
7
+ document gate and the checkout's structural requirements live here, not in a component.
8
8
 
9
9
  ```
10
10
  @v-office/sdk-core domain contracts + PMS adapters
11
11
  @v-office/website-sdk website facade (static + live namespaces)
12
- @v-office/website-headless ← THIS: orchestration hooks, validation, legal
13
- gates, enhancement engines
14
- @v-office/website-components imported UI over these hooks (a site may copy and own it)
12
+ @v-office/website-headless ← THIS: stores, rules, SDK runners, models, enhancement engines
13
+ @v-office/website-components imported UI over these stores (a site may build its own instead)
15
14
  userland pages, brand components, tokens, copy
16
15
  ```
17
16
 
18
- ## Entry points
17
+ **What it is not.** No React, no markup, no copy (stores take `messages` and return kinds and
18
+ numbers), no `window` or `document` in the `.` entry, no SDK built on its own initiative, and no
19
+ site-specific default: a difference between sites is a documented option whose default is right for
20
+ a site that never thought about it. Facts about the business (routes, the address decision, the
21
+ favourites namespace, the backend) have no default at all.
19
22
 
20
- | Import | Contents |
21
- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
22
- | `@v-office/website-headless` | Framework-free runtime: `createCheckoutFlow` — the whole checkout machine (stay → quote → selections → § 7 gate → book → pay) as a store with `getState` / `subscribe` / actions / `update` / `start` / `dispose`; `createStore`, the primitive every flow here builds on; `fetchQuote` + the quote view model, `book()`/`pay()` runners, the payment-return snapshot, validation rules, `CHECKOUT_PARTS` + `assertCheckoutStructure`, `logSdkFailure` |
23
- | `…/enhance` | Progressive enhancement for server-rendered markup: card image strips, history-backed content overlays, the rental gallery's layered viewer. They bind site markup and own none of it |
23
+ ## Contents
24
24
 
25
- The flows are stores: a UI calls `getState()`, `subscribe(listener)` and the
26
- actions, and feeds its inputs through `update()`. No framework code lives here.
27
- `@v-office/website-components` (React) ships `useCheckoutFlow` for React sites
28
- that render their own booking UI; a site that wants neither writes the binding
29
- itself — it is this small:
25
+ - [Install and entry points](#install-and-entry-points)
26
+ - [Wiring a site](#wiring-a-site)
27
+ - [Stores](#stores)
28
+ - [Conventions and shared types](#conventions-and-shared-types)
29
+ - [Search form](#search-form)
30
+ - [URL parameters](#url-parameters)
31
+ - [Guests](#guests)
32
+ - [Search store and live search](#search-store-and-live-search)
33
+ - [Offers](#offers)
34
+ - [Results map](#results-map)
35
+ - [Rental catalogue and detail model](#rental-catalogue-and-detail-model)
36
+ - [Cards and facts](#cards-and-facts)
37
+ - [Showcase selectors](#showcase-selectors)
38
+ - [Quote flow and availability](#quote-flow-and-availability)
39
+ - [Checkout flow](#checkout-flow)
40
+ - [Contact form](#contact-form)
41
+ - [Countries](#countries)
42
+ - [Legal documents](#legal-documents)
43
+ - [Favourites](#favourites)
44
+ - [Shared helpers](#shared-helpers)
45
+ - [Enhance engines](#enhance-engines)
46
+ - [Part vocabularies](#part-vocabularies)
47
+ - [Migrating from 0.1.x](#migrating-from-01x)
48
+ - [Development](#development)
49
+
50
+ ## Install and entry points
51
+
52
+ ```bash
53
+ pnpm add --save-exact @v-office/website-headless @v-office/website-sdk
54
+ ```
55
+
56
+ - **The only peer is `@v-office/website-sdk >=2.25.0 <3`.** There is no runtime dependency. Sites
57
+ pin exact versions; updates arrive through fleet rollouts.
58
+ - `maplibre-gl` is not a dependency: the map engines take a loader (`() => import("maplibre-gl")`)
59
+ from the caller.
60
+
61
+ | import | runs | contents |
62
+ | ------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
63
+ | `@v-office/website-headless` | Node (build), browser | every module below; nothing touches `window`/`document` at import, nothing is registered on `globalThis` until a page-shared instance is claimed |
64
+ | `@v-office/website-headless/enhance` | browser only | `enhance(root, options)` and the binders it composes over server-rendered markup, plus the MapLibre engines |
65
+
66
+ `ENHANCE_PARTS` and `GATE_MARKERS` are exported from both entries, so a build can render the markup
67
+ contract without importing browser code.
68
+
69
+ The documented exceptions to "no DOM in `.`" take what they need as an argument or only touch the
70
+ browser when called: `assertCheckoutStructure(root)` takes a DOM root; `browserStorage()`, the
71
+ favourites store and the payment-return snapshot use Web Storage and fail soft without it;
72
+ `loadRentalIndex` and `loadRentalFacts` call `fetch`; `resultsQueryFlagScript()` returns script
73
+ source for the page's `<head>`.
74
+
75
+ ## Wiring a site
76
+
77
+ ### The SDK setup
78
+
79
+ Every SDK-using surface takes one plain-data `SdkSetup` (`{ config, options? }`). A site writes it
80
+ once in `src/data/sdk.ts`:
81
+
82
+ ```ts
83
+ import { sdkConfigFromEnv, type SdkSetup } from "@v-office/website-headless";
84
+
85
+ export const SDK: SdkSetup = {
86
+ // Each name spelled out: Vite inlines only the `import.meta.env.NAME` it sees, so
87
+ // `import.meta.env` passed whole can arrive without them in a build.
88
+ config: sdkConfigFromEnv({
89
+ CMS_BACKEND: import.meta.env.CMS_BACKEND,
90
+ CMS_STAGE: import.meta.env.CMS_STAGE,
91
+ HUB_API_KEY: import.meta.env.HUB_API_KEY,
92
+ HUB_GRAPHQL_URL: import.meta.env.HUB_GRAPHQL_URL,
93
+ HUB_V0_API_BASE_URL: import.meta.env.HUB_V0_API_BASE_URL,
94
+ HUB_V1_API_BASE_URL: import.meta.env.HUB_V1_API_BASE_URL,
95
+ IMAGE_PROXY_BASE_URL: import.meta.env.IMAGE_PROXY_BASE_URL,
96
+ VOFFICE_LOCAL_DEV_ACCESS_TOKEN: import.meta.env.VOFFICE_LOCAL_DEV_ACCESS_TOKEN,
97
+ VOFFICE_API_ENDPOINT: import.meta.env.VOFFICE_API_ENDPOINT,
98
+ VOFFICE_SEARCH_ENDPOINT: import.meta.env.VOFFICE_SEARCH_ENDPOINT,
99
+ VOFFICE_IMAGE_BASE_URL: import.meta.env.VOFFICE_IMAGE_BASE_URL,
100
+ }),
101
+ // Only the options the site uses: the setup is serialized into every island.
102
+ options: { translationOverrides: {} },
103
+ };
104
+ ```
105
+
106
+ - `sdkConfigFromEnv(env)`: The fleet's env names → `SdkConfig`. `env` is an `EnvRecord`; it never
107
+ reads `process.env`; pass a literal that names each variable (above), or `process.env` in a Node
108
+ script. Only non-empty strings count (Vite's boolean flags are ignored). Throws one `SdkEnvError`
109
+ naming every missing or conflicting variable at once.
110
+ - `SDK_ENV`: The env names (a platform contract: the AI Studio builder reads `HUB_API_KEY`; renaming
111
+ one is a major).
112
+ - `SdkEnvError`: `missing: string[]`, `conflicting: string[]`; the message names variables, never a
113
+ value.
114
+ - `sdkConfigFor({ backend, stage, token })`: `SdkConfigForInput` → a full config with the platform's
115
+ public endpoints. Spread extras on top:
116
+ `{ ...sdkConfigFor(…), facilityObjectGroupRelationAttributeId: 123 }`.
117
+ - `VOFFICE_ENDPOINTS`, `HUB_ENDPOINTS`: `VofficeEndpointTable` / `HubEndpointTable`: the public
118
+ https endpoints (`VofficeEndpoints`, `HubEndpoints`) per `Stage` (`"development"`, `"production"`;
119
+ `STAGES` lists both).
120
+ - `SdkConfig`: The SDK's own `WebsiteSDKConfig`, with `backend` explicit on v10. A site with an
121
+ unusual setup (a dev proxy) writes the literal.
122
+ - `SdkOptions`: The SDK options as data (`translationOverrides`, `customAttributes`,
123
+ `rentalHighlightPrioritization`, …), without `searchAllRentalsAtOnce` (that is `SdkMode`).
124
+ - `SdkSetup`: `{ config: SdkConfig; options?: SdkOptions }`.
125
+ - `Backend`, `Stage`, `Locale`: `"v9" | "v10"`; `"development" | "production"`; the SDK's
126
+ `"de-DE" | "en-US"` (there is no third locale).
127
+
128
+ `sdkConfigFromEnv` rules, in order:
129
+
130
+ 1. `CMS_BACKEND` must be `v9` or `v10`. There is no silent default.
131
+ 2. The backend's token is required: `HUB_API_KEY` (v9, the public be-on key) or
132
+ `VOFFICE_LOCAL_DEV_ACCESS_TOKEN` (v10).
133
+ 3. With `CMS_STAGE` (`development` | `production`) set, the platform endpoints of that stage are
134
+ used and the URL variables must be absent (two sources for one value is an error, not a merge).
135
+ 4. Otherwise every URL variable of the backend is required: v9 `HUB_GRAPHQL_URL`,
136
+ `HUB_V0_API_BASE_URL`, `HUB_V1_API_BASE_URL`, `IMAGE_PROXY_BASE_URL`; v10 `VOFFICE_API_ENDPOINT`,
137
+ `VOFFICE_SEARCH_ENDPOINT`, `VOFFICE_IMAGE_BASE_URL`.
138
+
139
+ ### Which SDK instance
140
+
141
+ The package constructs an SDK only when asked. Every runner and store takes the SDK as its first
142
+ argument, as an `SdkSource`: an instance, or a getter (`() => WebsiteSdk | Promise<WebsiteSdk>`).
143
+ Every store calls a getter once, on its first request, and again only after a failed resolve, so a
144
+ getter that constructs (a lazy `shareSdk` claim) constructs once per store.
145
+
146
+ - `createSdk(setup, mode?)`: Build time and scripts. The caller owns the instance: every build-time
147
+ loader takes the same one (the API allows 1 request per second per instance), and the caller
148
+ disposes it (`await sdk.dispose()`) after the last loader, in a `finally`, so a failed loader
149
+ releases it too.
150
+ - `shareSdk(setup, mode?)`: Browser surfaces. Returns a `SharedSdk` (`{ sdk, release() }`): every
151
+ claim with an equal setup and mode (compared as key-sorted JSON) gets the page's same instance, so
152
+ one 1 req/s queue and one cache serve every surface. The last `release()` disposes it, unless a
153
+ new claim arrives within the same task (a React StrictMode remount keeps it).
154
+ - `loadSharedSdk(setup, mode?)`: `shareSdk` with the SDK imported on first call (a dynamic
155
+ `import()`): a page whose only SDK use is a submit (a contact form) ships no SDK code up front.
156
+ Resolves to the instance `shareSdk` holds for the same setup.
157
+ - `resolveSdk(source)`: The instance behind an `SdkSource` (a promise). Call it once per call site
158
+ and keep the result.
159
+ - `SdkMode`: `"default"` (paged search and `mapSearch`) or `"all-rentals-at-once"`
160
+ (`searchAllRentalsAtOnce`: at most 100 rentals priced in one call, no cursor, no `mapSearch`). A
161
+ store or walker over such an instance takes the same value as `sdkMode`.
162
+ - `WebsiteSdk`, `SdkSource`: A constructed SDK (either backend); what every store takes first.
163
+ - `describeSdkError(error)`: A failed SDK call and its whole `cause` chain as indented JSON (stacks
164
+ skipped). `console.error(error)` does not print the cause, which is where the backend's reason is.
165
+ - `logSdkFailure(scope, error)`: `console.error("[sdk] <scope> failed:", description)`, tagged by
166
+ SDK operation (`"search.search"`, `"contact.submit"`). Every runner in this package already logs
167
+ through it.
30
168
 
31
169
  ```ts
32
- import { useEffect, useRef, useSyncExternalStore } from "react";
33
170
  import {
34
- createCheckoutFlow,
35
- type CheckoutFlow,
36
- type CheckoutFlowOptions,
171
+ createSdk,
172
+ fetchLegalDocuments,
173
+ loadRentalCatalogue,
174
+ resolveFilters,
175
+ shareSdk,
37
176
  } from "@v-office/website-headless";
38
177
 
39
- export function useCheckoutFlow(options: CheckoutFlowOptions) {
40
- const ref = useRef<CheckoutFlow | null>(null);
41
- ref.current ??= createCheckoutFlow(options);
42
- const flow = ref.current;
43
- useEffect(() => flow.update(options));
44
- useEffect(() => {
45
- flow.start();
46
- return () => flow.dispose();
47
- }, [flow]);
48
- const state = useSyncExternalStore(flow.subscribe, flow.getState, flow.getState);
49
- return { ...state, ...flow };
178
+ // Build time: one instance for every loader, disposed after the last one.
179
+ const sdk = createSdk(SDK);
180
+ try {
181
+ const rentals = await loadRentalCatalogue(sdk, { locale: "de-DE" });
182
+ const filters = resolveFilters(
183
+ await sdk.static.filter.getFilters({ locale: "de-DE" }),
184
+ FILTER_SPEC,
185
+ );
186
+ const legal = await fetchLegalDocuments(sdk, "de-DE");
187
+ // … the plain results go to the pages; no page builds an SDK of its own
188
+ } finally {
189
+ await sdk.dispose();
190
+ }
191
+
192
+ // Browser: claim the page's instance, release it when the surface goes.
193
+ const shared = shareSdk(SDK);
194
+ // … createSearchStore(shared.sdk, …), createQuoteFlow(shared.sdk, …)
195
+ shared.release();
196
+ ```
197
+
198
+ Two surfaces whose setups differ in any option get two instances, each with its own rate limit. Pass
199
+ the one `SDK` constant everywhere.
200
+
201
+ ### Site constants
202
+
203
+ A site writes each decision once and passes the constant to every surface that needs it, so two
204
+ surfaces cannot disagree.
205
+
206
+ | constant | type | passed to |
207
+ | ----------------- | ----------------------------------------------------------- | -------------------------------------------------------------- |
208
+ | `SDK` | `SdkSetup` | every SDK-using surface |
209
+ | `SEARCH_CONTRACT` | `SearchContract` (`backend`, `nightRangeSearch`, `aliases`) | search form, search store, offer chips |
210
+ | `GUESTS` | `GuestOptions` (limits, child age band, default adults) | search form, quote flow, checkout flow |
211
+ | `DAY_PRICES` | `DayPrices` | the booking calendars (`dayPriceLabel`) |
212
+ | `POLICY_CHOICE` | `PolicyChoiceMode` | quote flow and checkout flow (`policyChoice`) |
213
+ | `BOOKABLE` | `BookableFrom` | the search form's `dates.bookable` and the quote's notice |
214
+ | `ADDRESS` | `RentalAddressMode` | `rentalAddressLine` (the rental heading and map) |
215
+ | `FAVORITES_KEY` | `string` from `favoritesStorageKey(namespace)` | `enhance()`, `createFavorites`/`acquireFavorites`, every heart |
216
+
217
+ `@v-office/website-components` adds `CARD` (`RentalCardOptions`, which extends `CardModelOptions`)
218
+ and `VOICE` (`Voice`).
219
+
220
+ ### Build time and browser
221
+
222
+ Everything in `.` runs in both places, but some calls belong to one of them:
223
+
224
+ - **Build time** (they spend rate-limited requests or need full ICU): `loadRentalCatalogue`,
225
+ `fetchRentalFlags`, `resolveSearchGroups`, `measureFilters`, `findOfferWindows`,
226
+ `findCampaignStart`, `fetchLegalDocuments`, `countryOptions`, `rentalIndexEntries`, `cardSource`,
227
+ `rentalFactsMap`, `buildShowcaseGroups`, `resolveFilters`. Their results are plain JSON and are
228
+ passed to islands as props.
229
+ - **Browser**: the stores (`createSearchForm`, `createSearchStore`, `createQuoteFlow`,
230
+ `createCheckoutFlow`, `createContactForm`, `createFavorites`), `loadRentalIndex`,
231
+ `loadRentalFacts`, `readPaymentReturn`, and all of `./enhance`.
232
+
233
+ ## Stores
234
+
235
+ Every flow is a store: a UI reads `getState()`, subscribes, and calls actions. The state object is a
236
+ new reference on every change and the same reference otherwise, so it works as a React external
237
+ store, a Vue `shallowRef`, a Svelte store or a plain listener.
238
+
239
+ | export | what it is |
240
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
241
+ | `Store<S>` | `getState(): S` and `subscribe(listener): Unsubscribe`. A listener is not called for the state current at subscription. |
242
+ | `WritableStore<S>` | Adds `setState(next \| (prev) => next)`; the same reference is a no-op. `S` is never a function itself. |
243
+ | `createStore(initial)` | The primitive every flow builds on (a custom UI may use it for its own state). |
244
+ | `Listener<S>`, `Unsubscribe` | The listener and its removal (calling it twice is harmless). |
245
+
246
+ Conventions every store follows:
247
+
248
+ - `create<Thing>(sdk?, options)` returns the store plus its actions and, where it holds timers or
249
+ subscriptions, a `dispose()` that stops them and drops late answers. A store never disposes the
250
+ SDK.
251
+ - Options are plain data (an island receives them as props). Stateful factories take
252
+ `now?: () => Date` for tests and read it when needed; pure rules take `today?: IsoDay`.
253
+ - `acquire<Thing>(key, create)` gives every island on the page the same instance through one
254
+ ref-counted registry on `globalThis[Symbol.for("@v-office/website-headless/<namespace>")]`, so two
255
+ bundles of this package (an island chunk and a layout script) still meet.
256
+
257
+ `@v-office/website-components` ships the React bindings (`useSearchForm`, `useSearchStore`,
258
+ `useQuoteFlow`, `useCheckoutFlow`, `useGuestForm`, `useContactForm`, `useFavorites`, `useSdk`,
259
+ `useLazySdk`, `useEnhance`). Any other framework needs only this:
260
+
261
+ ```tsx
262
+ import { useSyncExternalStore } from "react";
263
+ import type { Store } from "@v-office/website-headless";
264
+
265
+ export function useStore<S>(store: Store<S>): S {
266
+ return useSyncExternalStore(store.subscribe, store.getState, store.getState);
50
267
  }
51
268
  ```
52
269
 
53
- Vue: a `shallowRef` set in `subscribe`; Svelte: the store contract is the same
54
- two functions; a plain `<script>` island: call `getState()` in the listener.
270
+ A plain `<script>`:
271
+
272
+ ```ts
273
+ import { createSearchForm } from "@v-office/website-headless";
274
+
275
+ const form = createSearchForm({ ...SEARCH_CONTRACT, guests: GUESTS });
276
+ const stop = form.subscribe((state) => {
277
+ document.body.toggleAttribute("data-search-ready", state.resolved);
278
+ });
279
+ form.load(location.search);
280
+ // later: stop(); form.dispose();
281
+ ```
282
+
283
+ ## Conventions and shared types
284
+
285
+ ### The rules
286
+
287
+ - **Copy never enters headless.** Stores take `messages` objects (`CheckoutMessages`,
288
+ `ContactFormMessages`, `ValidationMessages`) and return kinds and numbers; the components' copy
289
+ tables satisfy those message types structurally. PMS strings (prices, reasons, labels) pass
290
+ through verbatim.
291
+ - **Dates are strings.** `IsoDay` (`YYYY-MM-DD`, a local calendar day) and `IsoMonth` (`YYYY-MM`) at
292
+ every public boundary; `Date` only for injected clocks.
293
+ - **Real data only.** Absent data is `undefined`, `null` or `[]`, never `0` or `""`: no fallback
294
+ town, no guessed count, no invented price. Money is the SDK's formatted string; no amount is
295
+ recomputed, and an offer's amount is carried nowhere.
296
+ - **The SDK comes in first** (`fetchQuote(sdk, request)`), and is constructed only by `createSdk`,
297
+ `shareSdk` and `loadSharedSdk`.
298
+ - **`book()`, `pay()` and the contact `submit()` are user-initiated only.** They create a real
299
+ reservation, start a real payment and post a real request into the PMS. No test, job or smoke step
300
+ calls them against a real SDK.
301
+ - **No site names.** The source names no customer, domain or town; a ruling is an option.
302
+
303
+ ### One name per concept
304
+
305
+ | name | type (where) | meaning |
306
+ | -------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
307
+ | `contract` | `SearchContract` (headless `url`) | how the search runs on this backend; one value for the search form, the search store and the offer chips |
308
+ | `guests` | `GuestOptions` (headless `guests`) | stepper limits, child-age band, default adults. `limits.pets`: 0 = no dogs, 1 = a yes/no toggle, more = a stepper; every dog control follows it |
309
+ | `whenEmpty` | `WhenEmpty` (headless `search`) | what a filter or option that matches nothing does: `"drop"`, `"disable"`, `"show"` |
310
+ | `glyphs` | `Glyph` vocabulary (headless `shared`); `GlyphMode` (components) | a closed list of glyph names the models emit (`GLYPHS`); components map names to icons and offer `"none" \| "default" \| "mapped"` |
311
+ | `voice` | `Voice` (components) | how German copy addresses the reader: `"neutral"` (default), `"du"`, `"sie"`; English ignores it. Headless carries no copy |
312
+ | `headingLevel` | `HeadingLevel \| "none"` (components) | the level of a component's own heading; a custom UI decides its own |
313
+ | `loadOn` | `"near" \| "click" \| "consent"` (components) | when third-party map tiles may load (default `"click"`). The headless map surface loads only when told: `start: "demand"` waits for `build()`, `"near"` loads once the container nears the viewport |
314
+
315
+ ### Events, storage keys and URL keys
316
+
317
+ These are contracts: renaming one is a major. Headless hands events to `onEvent` callbacks; the
318
+ components dispatch them on `window`, and a custom UI dispatches the same shapes. No detail carries
319
+ personal data.
320
+
321
+ | event (constant) | from | `detail` |
322
+ | --------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
323
+ | `voffice:checkout` (`CHECKOUT_EVENT`) | `createCheckoutFlow` `onEvent` | `CheckoutEventDetail`: `{ type: "rate-fault", rentalId, code }` once per stay · `{ type: "booked", rentalId, bookingNumber, kind, payable }` · `{ type: "payment-started", bookingNumber, method }` |
324
+ | `voffice:quote` (`QUOTE_EVENT`) | `createQuoteFlow` `onEvent` | `QuoteEventDetail`: `{ type: "rate-fault", rentalId, stayKey, code }` once per stay |
325
+ | `voffice:contact` (`CONTACT_EVENT`) | after `submit()` resolved `{ ok: true }` | `ContactEventDetail`: `{ type: "sent", subject }` |
326
+ | `merkliste:changed` (`FAVORITES_EVENT`) | `bindFavorites` (dispatched by headless) | `FavoritesChange`: `{ id, saved }`, once per changed rental, also for another tab's change |
327
+
328
+ | storage | key |
329
+ | ------------------------ | ------------------------------------------------------------------------------------------------------ |
330
+ | localStorage, favourites | `` `${namespace}-merkliste` `` (`favoritesStorageKey`) |
331
+ | sessionStorage, checkout | `voffice:checkout-context` (`CHECKOUT_CONTEXT_KEY`), `voffice:booking-return` (`BOOKING_SNAPSHOT_KEY`) |
332
+
333
+ The URL vocabulary is [`SEARCH_PARAMS`](#url-parameters); the sort values are
334
+ [`SORT_URL_VALUE`](#sort).
335
+
336
+ ### Shared types
337
+
338
+ | type | defined in | meaning |
339
+ | --------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------- |
340
+ | `Locale`, `Backend`, `Stage` | `sdk` | `"de-DE" \| "en-US"`; `"v9" \| "v10"`; `"development" \| "production"` |
341
+ | `SdkConfig`, `SdkOptions`, `SdkSetup`, `SdkMode`, `SdkSource`, `WebsiteSdk` | `sdk` | see [Wiring a site](#wiring-a-site) |
342
+ | `IsoDay`, `IsoMonth` | `shared` | calendar strings |
343
+ | `Party`, `GuestLimits`, `ChildAgeRange`, `GuestCaps`, `GuestOptions` | `guests` | one party model for the search form, the quote and the checkout |
344
+ | `PriceCents` | `shared` | how a formatted price string is trimmed (cards, pins, calendar cells) |
345
+ | `Glyph` | `shared` | the closed glyph vocabulary |
346
+ | `RichBlock`, `RichInline` | `shared` | sanitized PMS text as data |
347
+ | `ContactLink`, `BookableFrom` | `shared` | `{ label, href }`; "not bookable online before `from`" |
348
+ | `StorageLike` | `shared` | `getItem`, `setItem`, `removeItem` |
349
+ | `WhenEmpty` | `search` | `"drop" \| "disable" \| "show"` |
350
+ | `RentalCounts`, `RentalDistance`, `DistanceKind` | `rental` | counts and distances (absent = `undefined`) |
351
+ | `CardSource`, `RentalFacts`, `RentalFactsMap`, `StayPrice` | `cards` | the one card input; build-time facts per rental; a priced stay |
352
+ | `Stay` | `checkout` | `{ start, end, party }`, a complete stay |
353
+
354
+ ## Search form
355
+
356
+ Module `search-bar`. The search bar's state and rules: a date picker and a guest picker composed,
357
+ the region choice, and the pairs a native GET form submits. It never calls the SDK: the bar
358
+ navigates (the form works before hydration), and the results page refines in place.
359
+
360
+ ### `createSearchForm(options)`
361
+
362
+ `options` is a `SearchFormOptions`; returns a `SearchForm`. Throws (at build) when `fields` lists
363
+ `"pets"` while dogs are not offered (`guests.limits.pets` is 0).
364
+
365
+ | option | default | meaning |
366
+ | ---------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
367
+ | `backend`, `nightRangeSearch`, `aliases` | (the `SearchContract`) | `backend` is required |
368
+ | `fields` | `["region", "dates", "guests"]` | `SearchField[]` in render order; `"pets"` renders the dog control as its own field. A field that is absent leaves its URL keys carried, not owned |
369
+ | `regions` | `[]` | the selectable region values (labels are UI); `"region"` renders only with at least one |
370
+ | `regionSelect` | `"multi"` | `"multi"` (OR) or `"single"` (one region or all) |
371
+ | `dates` | `{}` | the date picker's options (`createDateSearch`, without `backend` and `nightRangeSearch`) |
372
+ | `guests` | `{}` | `GuestSearchOptions` (`GuestOptions` plus `showDefault`) |
373
+ | `childAges` | `"required"` | `"required"`: a child without an age stops the submit; `"optional"` submits the ages that exist |
374
+ | `preset` | none | a landing page's starting search (a query string): its owned keys seed the pickers only when the URL owns none, its other pairs ride along |
375
+ | `now` | `() => new Date()` | the clock |
376
+
377
+ `SearchFormState`: `fields` (the ones that render), `dates` (`DateSearchState`), `guests`
378
+ (`GuestSearchState`), `regions`, `carried` (pairs the bar does not own: sort, filters, property,
379
+ campaign, a site's own keys), `resolved` (false until `load()` ran: show skeletons, disable the
380
+ submit), `issue` (`SearchFormIssue`: `{ kind: "agesMissing" }` after a refused submit, cleared by
381
+ the next change).
382
+
383
+ | action | what it does |
384
+ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
385
+ | `dates`, `guests` | the composed `DateSearch` and `GuestSearch`; their actions drive the panels |
386
+ | `load(search)` | read the URL (and the preset) into the pickers |
387
+ | `toggleRegion(value)`, `selectRegion(value \| null)`, `clearRegions()` | the region choice; values outside `regions` are ignored |
388
+ | `pairs()` | `writeSearch(pickers) + carried`: the GET form's hidden inputs |
389
+ | `submit(current?)` | `{ ok: true, pairs }` or `{ ok: false, issue }`. Pass `location.search`: the carried pairs are re-read from it, so filters and sort changed in place ride along |
390
+ | `reset()` | pickers and regions back to nothing chosen; carried pairs stay |
391
+ | `update(options)`, `dispose()` | change options (not the backend); stop listening |
392
+
393
+ ```ts
394
+ import { createSearchForm } from "@v-office/website-headless";
395
+
396
+ const form = createSearchForm({ ...SEARCH_CONTRACT, regions: ["Nord", "Süd"], guests: GUESTS });
397
+ form.load(location.search);
398
+
399
+ const element = document.querySelector("form[data-search]");
400
+ element?.addEventListener("submit", (event) => {
401
+ event.preventDefault();
402
+ const result = form.submit(location.search);
403
+ if (!result.ok) return; // state.issue: open the guest panel and ask for the ages
404
+ const query = new URLSearchParams(result.pairs.map(([key, value]) => [key, value]));
405
+ location.assign(`/suchen?${query.toString()}`);
406
+ });
407
+ ```
408
+
409
+ ### `createDateSearch(options)`
410
+
411
+ The period a guest is picking (`DateSearchOptions` → `DateSearch`). The calendar always shows the
412
+ guest's own stay; only what a submit sends widens (± steps). Which controls exist comes from the
413
+ SDK's capability helpers for the backend.
414
+
415
+ | option | default | meaning |
416
+ | ------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
417
+ | `backend` | required | decides the flexible tab and how a ± step is spelled |
418
+ | `nightRangeSearch` | `true` | whether the backend prices a `minNights`/`maxNights` window |
419
+ | `flexSteps` | `[0, 1, 2, 3, 7, 14]` | ± chips in days; `[]` or `[0]` = no chip row. On v10 always, on v9 only with `nightRangeSearch` |
420
+ | `flexibleStays` | `["weekend", "week", "month"]` | `[]` removes the flexible tab (v10 only) |
421
+ | `nightRangeRow` | `false` | render the Min./Max. nights row (beside exact dates and a window of at least 2 nights) |
422
+ | `minStayNights` | `1` | closer departure days are disabled |
423
+ | `bookable` | none | `BookableFrom`: `from` floors the calendar, the month strip and ± windows; `deferred` is true while today is before `noticeUntil ?? from` |
424
+ | `now` | `() => new Date()` | read on every action, so a bar left open past midnight moves its floor |
425
+
426
+ `DateSearchState`: `mode` (`DateMode`: `"dates" | "flexible"`), `from`, `to`, `flexDays`,
427
+ `nightRange`, `stay` (`FlexibleStay | null`), `months`, `windowNights`, `controls` (`DateControls`:
428
+ `flexibleTab`, `flexSteps`, `nightRangeRow`: what to render now), `floor`, `deferred`,
429
+ `monthOptions` (twelve months from the floor's month) and `period` (`SearchPeriod | null`, what a
430
+ submit sends).
431
+
432
+ Actions: `pick(day)` (first click sets `from`, second `to`, a click on a finished stay starts a new
433
+ one), `isDisabled(day)`, `setMode`, `setFlexDays`, `setMinNights`, `setMaxNights` (moving one bound
434
+ past the other pushes it along), `setStay`, `toggleMonth`, `load(period)` (a period the controls
435
+ cannot show loads as empty), `reset()`, `update(options)`.
436
+
437
+ ### Period labels
438
+
439
+ - `describePeriod(period, locale, today?)` → `PeriodLabel` (`dates`, `flexDays`, `nights`, `stay`,
440
+ `months`): the summary parts of a `SearchPeriod`, a partial pick (`{ kind: "partial", from, to }`)
441
+ or `null`. No copy words: the UI adds "± {days} Tage" and the like.
442
+ - `formatStayRange(from, to, locale, today?)` → `"4. – 11. Sept."`,
443
+ `"30. Dez. 2026 – 3. Jan. 2027"`: the month once, the year only when it is not the current one,
444
+ months through `Intl` (the result cards use the same).
445
+
446
+ ### Rental jump
447
+
448
+ A rental-name search box over the site's static index (the live search has no free-text key, so this
449
+ is catalogue matching, never a filter).
450
+
451
+ - `rentalIndexEntries(rentals, href, { code? })` (build) → `RentalIndexEntry[]` (`id`, `name`,
452
+ `href`, `code?`, `place?` = the town). `href` is the site's own detail URL.
453
+ - `loadRentalIndex(url)` (browser) fetches the JSON once per URL per page; a failure is not cached.
454
+ Rows without a string `id`, `name` and `href` are dropped.
455
+ - `matchRentals(entries, query, options?)` (`RentalMatchOptions`: `limit` 8, `minChars` 2) →
456
+ `RentalMatch` (`hits`, `total`). Every query word must occur in the name, place or code, in both
457
+ umlaut spellings ("duene" and "dune" find "Düne"); ranked code exact, name exact, name prefix,
458
+ word start, contains.
459
+ - `foldSearchText(raw)`: lowercase, ä→ae ö→oe ü→ue ß→ss, other diacritics stripped.
460
+
461
+ ## URL parameters
462
+
463
+ Module `url`. The search funnel's URL vocabulary and its codec: written canonically, read leniently
464
+ (the SDK's aliases, the legacy `pets` flag and a site's own aliases are read, never written).
465
+
466
+ ### `SEARCH_PARAMS`
467
+
468
+ Every key of the funnel's wire format (`SearchParamKeys`). Links live in bookmarks, indexes and
469
+ mails: renaming one is a major.
470
+
471
+ | key | meaning |
472
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
473
+ | `start`, `end` | arrival and departure (`YYYY-MM-DD`); on a ± or night-range search the window's bounds |
474
+ | `dates`, `nights` | v10 ± window `DD-MM-YYYY,DD-MM-YYYY` and its stay length |
475
+ | `month`, `weekend` | v10 flexible months (`MM-YYYY`, repeatable) and the weekend flag |
476
+ | `minNights`, `maxNights` | the night range inside a `start`/`end` window |
477
+ | `flex` | the ± step the guest chose; UI only, never sent to the SDK |
478
+ | `adults`, `children`, `childrenAges`, `babies` | the party; `childrenAges` is one comma list aligned to `children` |
479
+ | `petsCount`, `pets` | dogs: search pages write `petsCount`, rental and checkout pages `pets` (an integer); old search links carry `pets` as a flag |
480
+ | `region`, `property` | scopes (`region` repeatable, OR-combined, applied in the browser) |
481
+ | `sort`, `campaign` | the results ordering; an offer campaign scope |
482
+ | `policy`, `kind`, `return`, `offer` | the chosen rate (rental → checkout), a booking kind, the payment-return marker, an offer label carried to the rental page |
483
+
484
+ ### The codec
485
+
486
+ - `SearchContract`: `{ backend, nightRangeSearch?, aliases? }`. `nightRangeSearch` (default `true`):
487
+ set `false` on a v9 hub that answers the night pair unpriced; no control writes it, nothing reads
488
+ or sends it
489
+ - `SearchParamAliases`: legacy keys read as a canonical one (`start`, `end`, `region`, `adults`,
490
+ `children`, `childrenAges`, `babies`, `petsCount`). `from`/`till`/`nights_min`/`nights_max` are
491
+ always read
492
+ - `readSearch(search, contract, today?)`: query string → `SearchQuery` (`period`, `party`,
493
+ `regions`, `carried`). Tolerant: anything unreadable is "not stated". A v9 link with v10 flexible
494
+ keys reads as no period; flexible months already past are dropped
495
+ - `writeSearch(query, contract, options?)`: `SearchQuery` → `QueryPair[]` (`WriteSearchOptions`:
496
+ `floor`, `defaultAdults`) spelled for the backend, in the order period, party, regions, carried.
497
+ Dogs as `petsCount`; a written period always travels with adults (`defaultAdults`, default 2: a
498
+ dated v10 search without occupancy answers nothing). `floor` (default today) clamps the lower end
499
+ of a ± window
500
+ - `searchHref(action, query, contract, options?)`: `action` (may carry a `#fragment`) plus
501
+ `writeSearch`
502
+ - `readParty(params, aliases?)`: the party from any funnel URL: `petsCount` wins over `pets`,
503
+ `pets=true` reads as 1, ages aligned to `children`; `null` without any party key
504
+ - `partyPairs(party, petsKey)`: the party as pairs (`petsKey` `"petsCount"` or `"pets"`); zero
505
+ counts omitted
506
+
507
+ `SearchPeriod` is a period by meaning; `writeSearch` spells it per backend:
508
+
509
+ | `kind` | fields | URL |
510
+ | ------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
511
+ | `exact` | `start`, `end` | `start` + `end` |
512
+ | `plusMinus` | `start`, `end` (the guest's stay), `days` | v10: `dates` + `nights` + `flex`; v9: the widened `start`/`end` + `minNights = maxNights` + `flex` (needs `nightRangeSearch`) |
513
+ | `nightRange` | `start`, `end`, `minNights`, `maxNights` | `start` + `end` + the pair (the window alone where the pair is not priced) |
514
+ | `flexible` | `months` (`IsoMonth[]`), `stay` (`"weekend" \| "week" \| "month"`) | v10 only: repeated `month` + `weekend=1`, `nights=7`, or the days of the earliest month |
515
+
516
+ ```ts
517
+ import { readSearch, searchHref, type SearchContract } from "@v-office/website-headless";
518
+
519
+ const contract: SearchContract = {
520
+ backend: "v9",
521
+ aliases: { start: ["checkin"], end: ["checkout"] },
522
+ };
523
+ const query = readSearch(
524
+ "?checkin=2027-05-10&checkout=2027-05-17&adults=2&sort=preis-auf",
525
+ contract,
526
+ );
527
+ // query.period: { kind: "exact", start: "2027-05-10", end: "2027-05-17" }; query.carried: [["sort", "preis-auf"]]
528
+ const href = searchHref("/suchen", { ...query, regions: ["Nord"] }, contract);
529
+ ```
530
+
531
+ ## Guests
532
+
533
+ Module `guests`. One party model and its rules, shared by the search form, the quote and the
534
+ checkout. Babies and dogs never count against a person cap. A type whose limit is 0 is "not
535
+ offered": it renders no control and keeps whatever value it arrived with ("hidden means not offered,
536
+ never not honoured").
537
+
538
+ - `Party`: `{ adults, children, childrenAges: (number | null)[], babies, pets }`. `adults` 0 = none
539
+ chosen yet; an age is `null` until chosen
540
+ - `GuestKind`: `"adults" | "children" | "babies" | "pets"`
541
+ - `GuestLimits`, `DEFAULT_GUEST_LIMITS`: stepper maxima; defaults adults 16, children 6, babies 5,
542
+ pets 4. 0 = not offered; pets 1 = a yes/no choice
543
+ - `ChildAgeRange`, `DEFAULT_CHILD_AGE`: the selectable child ages, default 2–17 (younger is a baby)
544
+ - `GuestOptions`: `{ limits?: Partial<GuestLimits>, childAge?, defaultAdults? }` (default adults 2):
545
+ the `GUESTS` constant
546
+ - `GuestCaps`: a rental's caps: `maxPersons`, `maxAdults` (availability) beat `maxGuests`
547
+ (catalogue, used where availability states none: v9 before SDK 2.28) beat the limits; a stated cap
548
+ above the limit wins (a 20-person house). `childrenAllowed: false` (v9 availability) offers no
549
+ children or babies; `maxPets` (v9 availability) caps dogs, `0` offers none; `petsAllowed: false`: no
550
+ dog can be added. A count that arrived by link and one of these rules out can be lowered and is
551
+ still sent
552
+ - `guestBounds(party, { limits?, caps? })`: `GuestBounds`: per kind a `GuestBound` (`offered`,
553
+ `min`, `max`) (adults min 1). Adults win a shared person cap: children get what the adults leave.
554
+ Compute the bounds from the party before an edit
555
+ - `clampParty(party, bounds)`: offered types clamped; a not-offered type keeps its value; `adults` 0
556
+ stays 0; ages follow the children count
557
+ - `agesComplete(party)`: every child has an age (the age prices the stay)
558
+ - `occupancyOf(party)`: the SDK's `QuoteOccupancy`; unchosen ages left out
559
+ - `stayKeyOf({ start, end, party })`: dates and party as one comparable string (supersession in the
560
+ quote and checkout)
561
+
562
+ ### `createGuestSearch(options?)`
563
+
564
+ The party a guest is building, as a store (the search form composes one; a custom guest UI may use
565
+ it alone). Options: `GuestOptions` plus `showDefault` (default `false`: start at `defaultAdults`
566
+ when the URL states no party) and `caps`.
567
+
568
+ State: `party`, `shownAdults` (chosen, or `defaultAdults`: what the stepper and the summary show and
569
+ a submit sends), `hasSelection`, `agesComplete` (always true while children are not offered),
570
+ `bounds`, `ageOptions`, `occupancy` (what a submit sends). Actions: `set(kind, count)`,
571
+ `setAge(index, age | null)`, `load(party | null)` (ages outside the band become unchosen, so the
572
+ guest is asked again), `reset()`, `update(options)`.
573
+
574
+ ## Search store and live search
575
+
576
+ Module `search`. The page's one live search: walk, scopes, browser filters, viewport, sort, campaign
577
+ and outcome. Grid, map, count, chips and filter islands read the same store, because the SDK queues
578
+ searches at 1 request per second.
579
+
580
+ ### `createSearchStore(source, options)`
581
+
582
+ `options` is a `SearchStoreOptions`; returns a `SearchStore` (`Store<SearchState>` plus
583
+ `SearchActions` and `dispose()`). **It starts searching when created.** It reads and writes the URL
584
+ only through the injected `history` port and never creates or disposes the SDK.
585
+
586
+ | option | default | meaning |
587
+ | ---------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
588
+ | `backend`, `nightRangeSearch`, `aliases` | (the `SearchContract`) | `backend` is required |
589
+ | `rentalHref` | required | a `RouteTemplate` with `{slug}` or `{id}` for rentals the facts give no `href` |
590
+ | `locale` | required | the language of the PMS strings and the stay labels |
591
+ | `history` | required | `HistoryPort`: `read()`, `write(search, "push" \| "replace")`, `subscribe(onChange)` (components: `browserHistory()`) |
592
+ | `seed` | required | the random-sort seed, drawn once per page view |
593
+ | `facts` | none | `RentalFactsMap`: build-time facts that fill what the live projection lacks and answer the browser filters |
594
+ | `filters` | none | `ResolvedFilters`; without it only period and occupancy reach the SDK |
595
+ | `scope` | none | `SearchScope` (below) |
596
+ | `favorites` | none | a `FavoritesPort` (`ids()` + `subscribe`); required for `scope.favorites`. `Favorites` satisfies it |
597
+ | `campaignOf` | `campaignGroup` | offer label → campaign group |
598
+ | `preset` | none | applied (`replace`) when the URL carries no search of its own |
599
+ | `requireFacts` | `false` | drop rentals the build has no facts for (no detail page was built) |
600
+ | `reveal` | `"complete"` | `"first-page"`: the first page shows at once; the browser sort, the outcome and the count wait for the rest |
601
+ | `sdkMode` | `"default"` | the mode the SDK was created in |
602
+ | `leaveMs` | `FAVORITE_LEAVE_MS` (280) | the favourites page's removal window; 0 = immediate (pass 0 under reduced motion) |
603
+ | `now` | `() => new Date()` | the clock (also `sanitizeSearch`'s today) |
604
+
605
+ `SearchScope` narrows the one result set, applied in this order: `ids` (a fixed set from the build:
606
+ a theme, a property, a landing page) → `favorites: true` (the Merkliste; on v10 restricted
607
+ server-side) → `offers` (discounted results only; `labels` narrows to exact PMS labels, `campaign`
608
+ to a whole campaign, `window: OffersWindowSpec` makes the page search its offers window when the URL
609
+ carries no period). Then the browser filters and `requireFacts`, the URL's `?campaign=`, the map
610
+ viewport and the sort.
611
+
612
+ **When a request runs.** On create the store sanitizes the URL (a past or unreadable stay is dropped
613
+ with `replace`), applies `preset` where the URL has no search, and the offers window where it has no
614
+ period. A new live search runs only when the SDK-facing part changes (period, occupancy, SDK filter
615
+ keys, an id restriction, a server sort while the set is incomplete). Facts filters, scopes, the
616
+ campaign and a browser sort re-derive from the loaded set without a request. A walk reads every page
617
+ (sequentially, capped at 100 pages); a v9 ± search also asks the guest's own dates exactly and
618
+ prefers those answers.
619
+
620
+ `SearchState`:
621
+
622
+ - `status`: `SearchStatus`: `"loading"` until the first answer, `"ready"`, or `"error"` only when
623
+ nothing loaded
624
+ - `complete`: every page is in (false while a first-page reveal loads, and after a later page
625
+ failed: what loaded stays)
626
+ - `search`, `shape`: the current query (sanitized, no `?`) and its `QueryShape`
627
+ - `scope`: `SearchScopeKind`: `"search"`, `"favorites"`, `"offers"`, `"ids"`
628
+ - `matched`: after scopes, filters and the campaign, before the viewport: what the map plots
629
+ - `items`, `count`: after the viewport and the sort: what the list renders, and how many (leaving
630
+ favourites excluded from the count)
631
+ - `allAlternatives`: every result is an alternative on an exact search (show the notice); waits for
632
+ `complete`
633
+ - `sort`, `sortOptions`: the `SortKey` in the URL; every key with whether it can be chosen now
634
+ - `filters`, `activeFilters`, `filterCounts`: the resolved filters; the applied ones (chips, hidden
635
+ included, led by a `?property=` or `?region=` scope the curation has no facet for: see
636
+ [Filters](#filters)); live counts over the listed set (`key` for a flag, `key=value` for a facet
637
+ value)
638
+ - `offerLabels`, `campaign`: distinct PMS offer labels of the scoped results (before `?campaign=`
639
+ narrows); the campaign in scope
640
+ - `outcome`: `SearchOutcome` (error or empty with ways out), or null while results show or the walk
641
+ has not settled
642
+ - `unusedFilterKeys`: keys the backend reported as having had no effect
643
+ - `highlighted`, `reloading`, `viewportActive`, `leaving`: the hovered card; the map's 220 ms
644
+ re-settle pulse; a viewport filter is active; favourites un-hearted a moment ago (still in
645
+ `items`, animating out)
646
+
647
+ | action | what it does |
648
+ | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
649
+ | `setSearch(search, mode = "push")` | change the query |
650
+ | `setSort(key)` | order in place (`replace`) |
651
+ | `applyFilters(selection)`, `removeFilter(filter)`, `clearFilters()` | the filter panel, a chip, "remove all" (push; hidden filters and implicit scopes included in `clearFilters`) |
652
+ | `setViewport(ids \| null)`, `setReloading(on)` | the map's in-view ids (results without coordinates always stay) and its pulse (the map controller calls these) |
653
+ | `releaseRegionScope()` | drop `?region=` (push) so neighbouring results come back |
654
+ | `setCampaign(group \| null)` | the campaign chip (`replace`); a `?campaign=` the complete results cannot honour clears itself |
655
+ | `setHighlighted(id \| null)`, `retry()` | the hovered card; re-run after an error |
656
+ | `dispose()` | stop listening to the URL and the Merkliste, drop a running walk (the SDK stays) |
657
+
658
+ ```ts
659
+ import {
660
+ acquireSearchStore,
661
+ createSearchStore,
662
+ loadRentalFacts,
663
+ shareSdk,
664
+ type HistoryPort,
665
+ } from "@v-office/website-headless";
666
+
667
+ const history: HistoryPort = {
668
+ read: () => location.search,
669
+ write: (search, mode) => {
670
+ const url = `${location.pathname}${search ? `?${search}` : ""}${location.hash}`;
671
+ if (mode === "push") window.history.pushState(null, "", url);
672
+ else window.history.replaceState(window.history.state, "", url);
673
+ },
674
+ subscribe: (onChange) => {
675
+ window.addEventListener("popstate", onChange);
676
+ return () => window.removeEventListener("popstate", onChange);
677
+ },
678
+ };
679
+
680
+ const shared = shareSdk(SDK);
681
+ const facts = await loadRentalFacts("/rental-facts.json");
682
+ const { store, release } = acquireSearchStore("results", () =>
683
+ createSearchStore(shared.sdk, {
684
+ ...SEARCH_CONTRACT,
685
+ locale: "de-DE",
686
+ rentalHref: "/unterkuenfte/{slug}",
687
+ history,
688
+ facts,
689
+ seed: Math.floor(Math.random() * 2 ** 31),
690
+ }),
691
+ );
692
+ const stop = store.subscribe((state) => {
693
+ if (state.status === "ready") console.log(`${state.count} results`);
694
+ });
695
+ // teardown: stop(); release(); shared.release();
696
+ ```
697
+
698
+ ### Page-shared stores
699
+
700
+ - `acquireSearchStore(key, create)` → `SearchStoreClaim` (`{ store, release() }`): the store under
701
+ `key`, created by the first caller. Every island of the page (grid, map, count, chips) claims the
702
+ same key; the last release disposes it.
703
+ - `connectSearchStore(key, onStore)` → detach: follow a store that may appear later (a satellite
704
+ island hydrating before the results); `onStore(null)` when it is released.
705
+
706
+ ### Result items
707
+
708
+ `SearchResultItem` is one rental in the result list (grid, map and count share it): `id`, `name`,
709
+ `location` (`""` when the feed states none), `href` (the site's page plus the handover query),
710
+ `hrefWithoutDates`, `images` (`CardImage[]`, WebP where served), `counts`, `highlights`,
711
+ `rentalType`, `price` (`StayPrice | null`), `stay` (`StayWindow | null`: the priced stay when it is
712
+ not simply the searched dates), `alternative`, `periods` (`AlternativePeriod[]`), `propertyId`,
713
+ `coords`, `rating` (from the facts only).
714
+
715
+ - `runLiveSearch(sdk, search, options)`: one request with the caller's SDK → `SearchPage` (`items`,
716
+ `shape`, `cursor`, `hasNextPage`, `totalCount`, `unusedFilterKeys`). `LiveSearchOptions`: the
717
+ contract, `rentalHref`, `locale`, `facts`, `filters`, `sort` (default `"recommended"`), `cursor`,
718
+ `rentalIdsIn` (v10). The store does not use it; it is for a one-off request
719
+ - `catalogueResultItem(rental, options)`: a catalogue rental as a result item for a server-rendered
720
+ listing (no price, no stay, a link without booking context)
721
+ - `compareUndatedSearchOrder(a, b)`: the order an undated search returns, as far as a build can
722
+ reproduce it (numeric id ascending on v9, code-unit order on v10), so the island's swap moves
723
+ nothing
724
+ - `resultCardSource(item, facts?)`: the `CardSource` of one result: live fields from the item,
725
+ catalogue fields from the facts
726
+ - `ResultLinkOptions`: `{ rentalHref, facts?, locale }`
727
+
728
+ ### Queries and links
729
+
730
+ - `toSdkQuery(search, options)`: URL query → `SdkSearchQuery` (`query`, `sort?`, `withheld`). An
731
+ allowlist: the period keys this backend executes, occupancy, and the resolved filters' SDK keys;
732
+ every other key stays in the URL. `SdkQueryOptions`: the contract, `sort`, `sdkFilterKeys`,
733
+ `retiredKeys`
734
+ - `queryShape(search, contract)`: `QueryShape`: `exactDates`, `priced` (a period and at least one
735
+ adult: totals arrive, price sort possible), `dated`, `nights`, `window`, `ownStay` (the v9 ± stay
736
+ asked as a second question), `hasSearch`
737
+ - `handoverQuery(search, options?)`: `ResultHandoverOptions` (`stay`, `offerLabels`). The `?…` a
738
+ result link carries: the period (or the given stay), the party (dogs as `pets`), at most four
739
+ `offer=` labels and only with a period
740
+ - `externalSearchHref(link, search, contract)`: a link to a partner's search from the current one
741
+ (`ExternalSearchLink`: `base`, `params` (partner name → `ExternalSearchSource`), `dateFormat`
742
+ `"iso"`, `"d.m.yyyy"` or `"dd.mm.yyyy"`). Values without data are omitted. Throws on a `base` that
743
+ is not an absolute URL
744
+ - `sanitizeSearch(search, today)`: `SanitizedSearch` (`search`, `dropped`): an unsearchable stay
745
+ (past, reversed, unreadable, half a pair) dropped with its night range and `flex`, instead of an
746
+ error page
747
+ - `resultsQueryFlagScript()`: inline `<head>` script source that sets `html[data-results-query]`
748
+ before first paint when the URL carries a real search, so a prerendered listing never flashes
749
+ under a search it does not answer
750
+
751
+ ### Sort
752
+
753
+ | `SortKey` | URL value (`SORT_URL_VALUE`) | runs |
754
+ | ------------- | ---------------------------- | --------------------------------------------------------------------------------------- |
755
+ | `recommended` | `empfohlen` | the backend's order; no sort sent |
756
+ | `price-asc` | `preis-auf` | priced queries only; sent to the SDK and re-applied in the browser over the merged list |
757
+ | `price-desc` | `preis-ab` | as `price-asc` |
758
+ | `rating` | `bewertung` | browser only, missing values last |
759
+ | `area-desc` | `groesse` | browser only |
760
+ | `area-asc` | `groesse-auf` | browser only |
761
+ | `guests` | `gaeste` | browser only |
762
+ | `bedrooms` | `schlafzimmer` | browser only |
763
+ | `random` | `zufall` | browser only, seeded per page view |
764
+
765
+ - `DEFAULT_SORT_OPTIONS`: `["recommended", "price-asc", "price-desc"]`, the short list for a site
766
+ that does not choose its own. Any known value in the URL is honoured even when the control does
767
+ not offer it.
768
+ - `sortFromSearch(search)` → `SortKey` (`"recommended"` when absent or unknown).
769
+ - `toSdkSort(key, shape)` → the SDK ordering, or `undefined` (price needs a priced query).
770
+ - `sortResults(items, key, seed)`: stable, missing values last, never as 0.
771
+ - `SortOptionState`: `{ key, available, reason?: "needs-price" | "needs-data" }`.
772
+
773
+ ### Filters
774
+
775
+ The package ships no curation: which keys, groups and order a portfolio needs is measured per site.
776
+ Every applied filter stays visible and removable; "hidden" means not offered as a control, never not
777
+ honoured.
778
+
779
+ `?region=` (the search bar's region field) and `?property=` (an Anlage link) narrow the results
780
+ even where the curation has no facet for them. The search store then lists them first in
781
+ `state.activeFilters` as facet chips with `scope: "region" | "property"` (`ImplicitScope`), an empty
782
+ `label` (the copy words them, "Region: {value}", "Anlage: {value}") and the URL value verbatim as
783
+ `value`; `removeFilter` and `clearFilters` remove them, and an empty result names them as the
784
+ `"filters"` cause. A curated (hidden) facet for the key replaces the implicit chip with its own label
785
+ and option labels, which is how a property chip shows the property's name instead of its id.
786
+
787
+ A `FilterSpec` is `{ groups: FilterGroupSpec[], retired?: string[] }`; a group is
788
+ `{ id, title, entries, hidden? }`; an entry (`FilterEntry`) is one of:
789
+
790
+ | `kind` | answered by | fields |
791
+ | --------- | ----------------------------------------------------------------- | --------------------------------------------------------------- |
792
+ | `sdk` | the backend (`getFilters`: a boolean, or an int minimum) | `key`, `label?` (overrides the SDK's), `max?` (stepper maximum) |
793
+ | `flag` | the browser, from `facts.flags[key]` | `key`, `label` |
794
+ | `minimum` | the browser, from `facts.counts[field]` or `facts.numbers[field]` | `key`, `label`, `field`, `min?` (1), `max` |
795
+ | `facet` | the browser, OR within, from `facts.facets[key]` | `key`, `label`, `options: FacetOption[]`, `multiple?` (true) |
796
+ | `pets` | occupancy `petsCount` | the dogs pill, placed where the entry sits |
797
+
798
+ Every entry and group may be `hidden`. `retired` keys are still executed by the backend but match
799
+ nothing here: they are stripped from every query, so an old link cannot empty the page invisibly.
800
+
801
+ - `resolveFilters(sdkFilters, spec, options?)`: the curation resolved against
802
+ `sdk.static.filter.getFilters` (its output passes as it is) → `ResolvedFilters` (`groups`, `defs`,
803
+ `sdkKeys`, `retired`), serializable. An SDK key the SDK no longer returns self-hides; option and
804
+ string filters in an `sdk` entry are skipped; int bounds default to 1..8.
805
+ `ResolveFiltersOptions`: `counts`, `total` (from `measureFilters`), `whenEmpty` (default
806
+ `"drop"`), `whenUniversal` (default `"drop"`: a filter matching everything does not filter)
807
+ - `measureFilters(sdk, keys, { locale, baseline? })`: build time, opt-in: hits per boolean key
808
+ against a baseline (default `"adults=1"`), one request per key, sequential → `FilterMeasurement`
809
+ (`total`, `counts`). A count the backend does not state is left out
810
+ - `facetOptions(facts, key, options?)`: facet options from the build's facts with their counts
811
+ (`FacetOptionsOptions`): `order` `"label"` (default), `"count-desc"` or an explicit list;
812
+ `labels`; `locale` (collation, default `"de-DE"`)
813
+ - `readSelection(search, filters)`, `writeSelection(search, filters, selection)`: the URL round trip
814
+ of a `FilterSelection` (`booleans`, `minimums`, `facets`, `pets`). Writing rewrites visible defs
815
+ only, keeps hidden ones, writes dogs as `petsCount` and never lowers the URL's dog count
816
+ - `activeFilters(search, filters)`: the applied filters of the curation as `ActiveFilter[]` (chips;
817
+ `id` is `key` or `key=value`; the copy words them). The store's `state.activeFilters` adds the
818
+ implicit scopes (above)
819
+ - `withoutFilter(search, filter)`, `withoutFilters(search, filters)`: the query without one chip, or
820
+ without every filter (dates, party and scopes kept)
821
+ - `minimumSteps(def, value)`: a minimum stepper's `{ down, up }`: the first step up lands on the
822
+ floor, the step down from the floor returns to "any"
823
+ - `quickFilterDefs(filters, keys)`: the quick strip's defs in `keys` order (visible booleans only)
824
+ - `FilterDef`, `SdkFilterDefinition`, `FacetOption`, `WhenEmpty`, `ImplicitScope`: the resolved def
825
+ union (`boolean`, `minimum`, `facet`, `pets`), the minimal shape read off `getFilters` (every kind
826
+ it returns), one facet value, the zero-match decision, `"region" | "property"`
827
+
828
+ ```ts
829
+ import { resolveFilters, type FilterSpec } from "@v-office/website-headless";
830
+
831
+ const FILTER_SPEC: FilterSpec = {
832
+ groups: [
833
+ {
834
+ id: "features",
835
+ title: "Ausstattung",
836
+ entries: [
837
+ { kind: "sdk", key: "sauna" },
838
+ { kind: "flag", key: "seaView", label: "Meerblick" },
839
+ { kind: "pets" },
840
+ ],
841
+ },
842
+ {
843
+ id: "size",
844
+ title: "Größe",
845
+ entries: [
846
+ { kind: "minimum", key: "bedrooms", label: "Schlafzimmer", field: "bedrooms", max: 6 },
847
+ ],
848
+ },
849
+ ],
850
+ };
851
+ // Build time, over the loaders' one SDK (see "Which SDK instance").
852
+ const filters = resolveFilters(
853
+ await sdk.static.filter.getFilters({ locale: "de-DE" }),
854
+ FILTER_SPEC,
855
+ );
856
+ ```
857
+
858
+ ### Outcome
859
+
860
+ `describeOutcome(input: OutcomeInput)` → `SearchOutcome | null` (null when there are results). The
861
+ store computes it; a custom UI reads `state.outcome`.
862
+
863
+ - `{ kind: "error" }`, or `{ kind: "empty", scope, causes, searched, relaxations }`.
864
+ - `scope`: `"search"`, `"favorites-none-saved"`, `"favorites-none-match"`, `"offers"` (nothing
865
+ reduced in this period, normal much of the year), `"ids"`.
866
+ - `causes` (`EmptyCause[]`, most actionable first): `"filters"`, `"pets"`, `"dates"`, `"party"`
867
+ (more than 2 guests). An empty Merkliste has none.
868
+ - `relaxations` (`Relaxation[]`): one concrete query per cause, guaranteed to differ from the
869
+ current one: `"without-filters"`, `"without-pets"`, `"without-dates"` (removes every period key),
870
+ `"fewer-guests"` (2 adults).
871
+ - `searched` (`SearchedParts`): `guests`, `dated`, `pets`, `filters`.
872
+
873
+ ### The paged walker
874
+
875
+ Module `sdk`. Every live-search walk (the store, `fetchRentalFlags`, `resolveSearchGroups`,
876
+ `findOfferWindows`) pages through one walker, so the paging rules cannot drift.
877
+
878
+ - `SEARCH_PAGE_SIZE` = 100 (below the v9 hub's per-request price cap; constant within a walk).
879
+ - `fetchSearchPage(sdk, request, cursor?)` → one `RawSearchPage`; a failure is logged as
880
+ `"search.search"` and rethrown.
881
+ - `walkSearchPages(sdk, request, options?)` (`WalkOptions`: `maxPages`, `signal`): an async
882
+ generator over every page, strictly sequential; its return value is `WalkSummary` (`pages`,
883
+ `truncated`). No cap by default: a walk is never silently cut. Throws `RangeError` for
884
+ `maxPages < 1`.
885
+ - `SearchPageRequest`: `query` (exactly as the SDK takes it), `locale`, `sort?`, `rentalIdsIn?` (v10
886
+ only; a v9 request carrying it throws), `sdkMode?`. A field sort on v9 throws.
887
+
888
+ ## Offers
889
+
890
+ Module `search`. Offers pages: the window an offers page searches, the campaign grouping, the
891
+ build-time sweep for each campaign's nearest window, and the chip row. A promotion belongs to a
892
+ rental × window pair: there is no offer filter key, and a discount is revealed only by pricing a
893
+ window, so every window number is a portfolio fact the site measured.
894
+
895
+ - `OffersWindowSpec`: `startOffsetDays` and `nights` (required), `flexDays` (14), `adults` (2),
896
+ `earliestArrivalOffset` (1), `seasonStart` (an absolute first arrival, from `findCampaignStart`)
897
+ - `offersWindowQuery(spec, today)`: the query an offers page searches today: `start`/`end` +
898
+ `minNights = maxNights` + `flex` + `adults`, the ± shape both backends execute. Throws on a spec
899
+ no window can come from
900
+ - `findOfferWindows(sdk, options)`: `FindOfferWindowsOptions`. Build time, opt-in: one walk per
901
+ offset (default `[0, 90, 180, 270]`, `spanDays` 30, `maxPages` 8 per offset), sequential →
902
+ `OfferWindow[]`, one per campaign at its nearest offset. `nights` and `locale` are required.
903
+ Cached per process and arguments; `[]` when nothing is discounted
904
+ - `findCampaignStart(sdk, options)`: `FindCampaignStartOptions`. Build time: the first arrival day
905
+ (an exact stay of `nights`, default 7, probed weekly up to `horizonDays`, default 180) on which
906
+ `campaign` is offered, or null. Feeds `seasonStart`
907
+ - `offerWindowHref(action, window, contract)`: the search an `OfferWindow` links to (its ± window,
908
+ its party, `campaign=`)
909
+ - `campaignGroup(label)`: the default `CampaignOf`: strips a trailing percentage only ("Frühbucher
910
+ 10 %" → "Frühbucher"), so it fails by not grouping, never by merging two campaigns
911
+ - `campaignChips(input)`: `CampaignChipsInput` (`state`, `windows`, `action`, `contract`, `locale`)
912
+ → the chip row (`CampaignChip[]`): `toggle` chips for campaigns at home in the current window,
913
+ `link` chips (with `href` and the `period` text) for campaigns at home elsewhere. No counts
914
+
915
+ The search store takes the page's scope as `scope: { offers: { campaign?, labels?, window? } }`.
916
+
917
+ ## Results map
918
+
919
+ Module `map`. The results map's pure rules and its behaviour as a store over the search store. The
920
+ MapLibre engines that draw it are in [`./enhance`](#map-engines).
921
+
922
+ ### `createResultsMapController(source, options?)`
923
+
924
+ `options` is a `ResultsMapOptions`; returns a `ResultsMapController`. `source` is the search store
925
+ (`MapResultsSource`: `getState`, `subscribe`, `setViewport`, `setReloading`, `releaseRegionScope`).
926
+ The map never writes into card markup: it tells the store which ids are in view and the list
927
+ follows. Rules, in order:
928
+
929
+ 1. The surface that drives the list is the open fullscreen surface, else the inline one.
930
+ 2. While `listFollowsMap` is false, before the guest moved the map, or while the driving surface has
931
+ no size: no viewport filter (every card shows).
932
+ 3. A guest move whose in-view set differs from the last: the 220 ms pulse (`RELOAD_FLASH_MS`), then
933
+ the new set.
934
+ 4. New points before a guest move refit the camera (`frameVersion + 1`); after one the camera stays.
935
+ 5. The open popup closes when its rental leaves the points.
936
+ 6. `regionScope: "release-on-move"`: the first guest move drops the `?region=` scope.
937
+
938
+ | option | default | meaning |
939
+ | ---------------- | ---------------------------- | --------------------------------------------------------------------------------------------- |
940
+ | `listFollowsMap` | `true` | the list shows only the results in the visible area once the guest moved |
941
+ | `regionScope` | `"keep"` | `"release-on-move"` |
942
+ | `sameSpot` | `"fan"` | `SameSpotMode` (below) |
943
+ | `fit` | `DEFAULT_FIT` (`DefaultFit`) | `FitOptions`: `padding` 56, `maxZoom` 12, `excludeOutliers` true |
944
+ | `pins` | `{}` | `PinLabelOptions`: `price` `"total"` or `"hidden"` (every pin a dot), `cents` (`"hide-zero"`) |
945
+
946
+ `ResultsMapState`: `status` (`"waiting"` while the search loads: fetch no tiles, `"empty"`,
947
+ `"ready"`), `points`, `priced`, `layout`, `frame`, `frameVersion`, `openId`, `expandedGroup`,
948
+ `guestMoved`. Actions: `report(surface, view)` (`ResultsMapSurface` `"inline"` or `"fullscreen"`,
949
+ `MapViewReport` `{ bounds, byGuest }`) after every `moveend` and size change (`bounds: null` for a
950
+ surface without size), `setPinWidth(px)`, `openPin(id)`, `closePopup()`, `toggleGroup(key | null)`,
951
+ `dispose()`.
952
+
953
+ ### The pure rules
954
+
955
+ - `mapPoints(items, options?)`: mappable results in result order → `MapPoint[]` (`id`, `at: LngLat`,
956
+ `label` or null for a dot, `sortValue`); items without coordinates are skipped
957
+ - `hasMapPoints(items)`: at least one result can be pinned (the map's and the mobile map pill's
958
+ condition)
959
+ - `frameFor(points, options?)`: the first frame (`MapBounds`) or null. Outliers (more than 10× the
960
+ median distance, from 4 points) do not aim the camera but stay pinned
961
+ - `idsInView(points, bounds)`: the ids inside the bounds (edges included)
962
+ - `layoutSameSpot(points, options)`: `SameSpotOptions` (`mode`, `pinWidthPx`) → `SameSpotLayout`
963
+ (`offsets`: a `PixelOffset` per id; `groups`: `SameSpotGroup[]`). `"fan"`: 2–5 pins cascade, 6 or
964
+ more sit on a circle. `"group"`: 3 or more collapse into one count bubble (single-building
965
+ portfolios)
966
+ - `circleOffsets(count, pinWidthPx)`, `distanceMeters(a, b)`: the circle layout; the haversine
967
+ distance
968
+ - `mergePlaces(places)`: fixed places (`MapPlace`: `name`, `lat`, `lng`) within `SAME_SPOT_METERS`
969
+ (12 m) merged into one `PlaceMarker` (`at`, `names`)
970
+ - `rentalAddressLine(rental, mode)`: the printed address line: `"street"` (street, postcode, town;
971
+ the town when the feed has no street) or `"town"`; null when the feed states nothing. Throws
972
+ `RentalAddressModeError` for any other value: the decision has no default, so an undecided page
973
+ fails its build. It decides the printed line only; the marker stays on the PMS coordinates
974
+ - `DEFAULT_MAP_STYLE_URL`: OpenFreeMap "Bright" (no key). A third-party request: load it on the
975
+ visitor's click unless the site's consent covers the tile host
976
+ - `DEFAULT_CLUSTER`: `{ radius: 54, maxZoom: 11 }`; `ClusterOptions.whenPriced` overrides it for
977
+ priced pins
978
+
979
+ ## Rental catalogue and detail model
980
+
981
+ Module `rental`. The rental page's data model, built at build time from `static.rentals.getRentals`.
982
+ Plain JSON; absence is `undefined` or `[]`; PMS strings are verbatim, and every word a page adds is
983
+ UI copy.
984
+
985
+ ### `loadRentalCatalogue(sdk, options)`
986
+
987
+ `options` is a `LoadRentalCatalogueOptions`. Build time. `getRentals` (one retry after 1.5 s) →
988
+ `fetchRentalFlags` → `assignSlugs` → `adaptRental` → `rankHighlights`, sorted by name in `locale` →
989
+ `RentalDetail[]`.
990
+
991
+ | option | default | meaning |
992
+ | -------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
993
+ | `locale` | required | the catalogue's language |
994
+ | `flags` | `DEFAULT_FLAG_FILTERS` | flag → dateless search key; `false` skips the flag searches |
995
+ | `slugOverrides` | none | rental id → slug (a template's fixed short slugs); two equal overrides throw |
996
+ | `universalShare` | 0.9 | highlights on at least this share of the portfolio move to the back (`rankHighlights`) |
997
+ | `markup` | `"light"` | `**bold**` and `- ` list lines become structure, so no asterisk reaches a guest; `"plain"`: verbatim |
998
+ | `publishArrivalInfo` | `false` | publish `description.arrivalInfo`. On some tenants it holds key-safe codes: turn it on only after reading the feed |
999
+ | `propertyImages` | `"omit"` | `"append"` the property's photos after the rental's own |
1000
+ | `excludeCategories` | none | amenity categories dropped besides `ALWAYS_EXCLUDED_CATEGORIES` (`Beschreibungen`, `Descriptions`, `System`) |
1001
+ | `hideRoomAttributes` | none | room attribute prefixes never shown besides `HIDDEN_ROOM_ATTRIBUTES` (the floor covering) |
1002
+ | `forbid` | none | `RegExp[]` no published string may match; a match throws `RentalContentError` (`rentalId`, `field`, never the matched text) |
1003
+ | `report` | one `console.warn` per kind | build-time notes (`AdaptEvent`: `duplicate-images`, `floorplan-not-rewritten`, `review-order-kept`) |
1004
+
1005
+ ```ts
1006
+ import {
1007
+ cardSource,
1008
+ createSdk,
1009
+ loadRentalCatalogue,
1010
+ rentalFactsMap,
1011
+ } from "@v-office/website-headless";
1012
+
1013
+ const sdk = createSdk(SDK);
1014
+ try {
1015
+ const rentals = await loadRentalCatalogue(sdk, { locale: "de-DE", forbid: [/tresor-?code/i] });
1016
+ const sources = rentals.map((rental) =>
1017
+ cardSource(rental, { rentalHref: "/unterkuenfte/{slug}" }),
1018
+ );
1019
+ const facts = rentalFactsMap(sources); // written as JSON for the search pages
1020
+ console.log(Object.keys(facts).length);
1021
+ } finally {
1022
+ await sdk.dispose();
1023
+ }
1024
+ ```
1025
+
1026
+ ### `RentalDetail`
1027
+
1028
+ - `id`, `slug`, `name`: the PMS id; the slug from `assignSlugs` (search results link with the same
1029
+ map); `nameOrLabel` verbatim
1030
+ - `town`, `address`: `address.city` or undefined (no fallback town); `RentalAddress` (`street`,
1031
+ `postalcode`, `city`, `province`, `country`)
1032
+ - `coords`, `type`: the PMS coordinates; the SDK-translated rental type
1033
+ - `counts`: `RentalCounts` (`guests`, `bedrooms`, `bathrooms`, `rooms`, `area`), what the feed
1034
+ states
1035
+ - `flags`: `Partial<Record<RentalFlag, boolean>>` from `fetchRentalFlags`; a flag the backend could
1036
+ not answer is absent
1037
+ - `distances`: `RentalDistance[]`, one per kind, in feed order
1038
+ - `images`, `floorplans`: `RentalImage[]` (`id`, `src`, `srcset?`, `caption?`, `width?`, `height?`,
1039
+ `source`): photos deduplicated and WebP where served; floor plans as uncropped renditions
1040
+ - `description`: `DescriptionContent`: `headline?`, `intro?`, `body: RichBlock[]`, `arrivalInfo?`
1041
+ (only with `publishArrivalInfo`)
1042
+ - `highlights`: SDK highlights, reordered by `rankHighlights`, never dropped (count echoes stay;
1043
+ skip them with `isSpecEcho`)
1044
+ - `amenities`, `themes`, `vicinity`: `AmenityGroup[]` (`label`, `items`) without unsafe categories
1045
+ and prose; vicinity "Themen" items; the other vicinity items that are not distances
1046
+ - `rooms`: `RentalRoom[]`: `type`, `kind` (`RoomKind`: `bedroom`, `bathroom`, `living`, `kitchen`,
1047
+ `other`), `ordinal?`, `description?`, `beds: BedRun[]` (consecutive identical beds as a count),
1048
+ `attributes`
1049
+ - `reviews`: `RentalReviews`: `summary?` (`ReviewSummary`: `score`, `scale`, `count`, `grade?` (the
1050
+ PMS's own), `criteria: ReviewCriterion[]`) and `items` (`RentalReview[]` with text, newest first;
1051
+ `reviewer` shortened)
1052
+ - `property`: `RentalPropertyInfo` (`id`, `name`, `description`, `highlights`, `images`) when the
1053
+ feed states one
1054
+
1055
+ ### The pieces
1056
+
1057
+ - `adaptRental(raw, options)`: one `CatalogueRental` (a `getRentals` item) → `RentalDetail`;
1058
+ `AdaptRentalOptions` are the options above plus `slug` and `flags`. Pure apart from `report`;
1059
+ never invents a value
1060
+ - `fetchRentalFlags(sdk, options)`: `FetchRentalFlagsOptions` (`locale`, `filters`). Build time: one
1061
+ unfiltered baseline plus one dateless `<key>=true` search per flag → `RentalFlagsResult`
1062
+ (`byRental`, `ignored`). A key the tenant ignores (reported in `unusedFilterKeys`, or on v9
1063
+ matching every rental: the hub answers an unknown key with everything) or a failed search leaves
1064
+ the flag absent; on v10 a flag every rental has stays `true`. Never fails the build
1065
+ - `DEFAULT_FLAG_FILTERS`: `{ petsAllowed: "pets", atBeach: "locBeach", inCity: "locCity" }`; a site
1066
+ adds keys its tenant supports
1067
+ - `assignSlugs(rentals, { overrides? })`: deterministic, collision-free slugs in id order (a
1068
+ collision gets the first 6 id characters, then the whole id)
1069
+ - `slugify(name)`: German transliteration, lowercase, hyphenated, at most 60 characters
1070
+ - `rankHighlights(rentals, { universalShare? })`: moves labels that almost every rental carries to
1071
+ the back; reorders, never drops
1072
+ - `parseCounts({ roomSummary, highlights })`: counts from the SDK's count lines (every wording of
1073
+ both backends and locales)
1074
+ - `parseDistances(vicinity)`, `distanceFact(distances, kinds?)`: distances in metres; the first
1075
+ stated distance of the first kind in `kinds` (default `WATER_KINDS`: beach, sea, lake), by
1076
+ priority, never the nearest
1077
+ - `isAdjacent(distance)`, `ADJACENT_BELOW_M`: below 25 m a distance is a statement ("Direkt am
1078
+ Strand"), not a number
1079
+ - `isSpecEcho(label)`: a highlight that only restates a count ("4 Schlafmöglichkeiten", "61 m²")
1080
+ - `amenityPreview(rental, options?)`: the short preview before "all amenities" →
1081
+ `{ items: AmenityPreviewItem[], total }`. `AmenityPreviewOptions`: `from` `"highlights"` (default)
1082
+ or `"categories"`, `count` (8), `lead` (facts that lead the rows: `"distance"`, `"pets"`; default
1083
+ none), `distanceKinds`
1084
+ - `descriptionPreview(blocks, strategy?)`: the visible part of a description → `DescriptionPreview`
1085
+ (`shown`, `hasMore`). `PreviewStrategy`: `{ by: "sentences", budget = 320, min = 160 }` (default;
1086
+ never ends mid-sentence), `{ by: "paragraphs", count }`, `{ by: "none" }`
1087
+ - `descriptionSummary(content, { max = 160 })`: plain text for a meta description: the first body
1088
+ paragraph that is not a bold-only headline (feed texts often open with `**Name – Slogan**`), the
1089
+ first paragraph when every one is, cut at a word boundary
1090
+ - `shortReviewerName(name)`, `splitReviewAuthor(author)`: "Max Muster" → "Max M."; PMS placeholders
1091
+ → undefined (the UI says "Gast"); the author string split into name and stay
1092
+ - `newestReviewsFirst(items, locale)`: newest first by the stay month the author string names; the
1093
+ feed order when any month does not read in `locale`
1094
+ - `webpUrl(url)`, `webpSrcset(srcset)`, `floorplanUrl(src)`: WebP for the two image hosts that serve
1095
+ it; an uncropped floor-plan rendition (or undefined)
1096
+
1097
+ ## Cards and facts
1098
+
1099
+ Module `cards`. One card input (`CardSource`) and the card model every renderer draws
1100
+ (`toCardModel`): showcase and result cards, the map popup, a site's own UI. Pure and deterministic
1101
+ at build and in the browser.
1102
+
1103
+ ### `CardSource`
1104
+
1105
+ Catalogue fields from the build plus optional live fields from the results; plain JSON, so it
1106
+ crosses an island boundary. `id`, `name`, `href`, `location?`, `street?`, `regions?`, `images`
1107
+ (`CardImage`: `src`, `srcset?`, `alt` or null), `counts?`, `distances?`, `highlights?` (pill
1108
+ candidates in lead order), `stated?` (exact strings for `{ stated }` matching, never rendered),
1109
+ `ownerPills?` (the owner's labels, always first), `flags?`, `facets?`, `numbers?` (free numeric
1110
+ facts for `minimum` filters and `{ number }` matching), `rentalType?`, `rating?` (`CardRating`:
1111
+ `value`, `count`, `scale?`), `blurb?`, `coords?`, `propertyId?`; live: `price?` (`StayPrice`),
1112
+ `nights?`, `stay?` (`StayWindow`), `alternative?`, `periods?` (`AlternativePeriod[]`).
1113
+
1114
+ - `cardSource(rental, extras?)`: build time: a `CardSource` from a `RentalDetail`.
1115
+ `CardSourceExtras`: `href` or `rentalHref` (one is required: it throws without), `ownerPills`,
1116
+ `highlights` (site-derived, placed before the PMS's), `blurb`, `location`, `regions`, `flags`,
1117
+ `facets`, `numbers`
1118
+ - `rentalFactsMap(sources)`: the `RentalFactsMap` the search store takes, keyed by id; live keys and
1119
+ `images` dropped (it ships on every search page)
1120
+ - `withRentalFacts(live, facts)`: live fields win where they state a value; facts fill the gaps;
1121
+ non-empty facts `highlights` win (the build ranked them)
1122
+ - `loadRentalFacts(url)`: the facts map from JSON; a missing or broken file is an error, never an
1123
+ empty set
1124
+ - `RentalFacts`, `RentalFactsMap`, `CardLiveField`: build-time facts per rental (`CardSource`
1125
+ without `id` and the live fields), by id; the live keys
1126
+ - `StayPrice`: `total` (the stay total, never per night), `original` (the struck price),
1127
+ `discounted`, `offerLabels` (the PMS's words, never an amount)
1128
+ - `StayWindow`, `AlternativePeriod`: the priced stay when it is not the searched dates (`source`:
1129
+ `own-dates`, `hub-chosen`, `alternative`); one suggested window of a non-exact result with its
1130
+ link and price
1131
+
1132
+ ### `toCardModel(source, options?, context?)`
1133
+
1134
+ → `RentalCardModel`: `id`, `name`, `href`, `location`, `blurb`, `images` (at most `maxImages`),
1135
+ `facts` (`CardFact[]`, each only when stated), `pills` (`CardPill[]`), `badges` (`CardBadge[]`),
1136
+ `status` (`CardStatus`: `{ kind: "offer", campaign }` beats `{ kind: "alternative-period" }`, which
1137
+ shows only on exact searches), `rating` (only with reviews), `price` (formatted by `priceCents`; the
1138
+ struck `original` is null when it equals the total after rounding), `nights`, `stay`, `periods`,
1139
+ `offerLabels`. `CardContext.exactSearch` (from `QueryShape.exactDates`) gates the alternative chip.
1140
+
1141
+ | `CardModelOptions` | default | meaning |
1142
+ | ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
1143
+ | `facts` | `["guests", "bedrooms", "area", "distance"]` | `FactKind[]` in display order; omit `"distance"` for no water fact |
1144
+ | `distance` | `{ prefer: WATER_KINDS, as: "fact" }` | which distance, and whether it sits in the facts or as pill 0 (`as: "pill"`) |
1145
+ | `pills` | `{ limit: 3, fill: true }` | `PillOptions` (below), or `false` for no row |
1146
+ | `badges` | none | `{ rules: BadgeRule[], limit = 2 }`: photo claims from a signal (`when: CardMatch`, `label`, `icon?`, `group?`: one badge per group) |
1147
+ | `maxImages` | 5 | 1 = a still |
1148
+ | `priceCents` | `"hide-zero"` | `PriceCents` for the total, the struck price and every period price |
1149
+ | `offer` | `"footer"` | where the PMS offer labels go: `"footer"`, `"pills"`, or `"badge"` (the status chip names the first label) |
1150
+
1151
+ `PillOptions`: `limit`, `fill` (false = owner pills only), `only` (an exclusive allowlist of feed
1152
+ highlights, exact equality), `prefer` (these lead the feed part), `exclude`, `rename` (display
1153
+ names), `glyphMap` (a `Glyph` per feed label; absent = text only). Owner pills always lead, outside
1154
+ `only`, `prefer` and `exclude`; a badge's label never repeats as a pill; count echoes are dropped by
1155
+ shape.
1156
+
1157
+ ### Signals: `matches(source, match)`
1158
+
1159
+ A `CardMatch` is a `CardSignal` or `{ anyOf: CardMatch[] }` / `{ allOf: CardMatch[] }` (an empty
1160
+ `anyOf` matches nothing, an empty `allOf` everything). Nothing is inferred: no distance threshold
1161
+ stands in for a flag, no substring for a label, an absent value never matches.
1162
+
1163
+ | signal | holds when |
1164
+ | ------------------------------------------------------------ | ------------------------------------------------------- |
1165
+ | `{ flag: key }` | `flags[key]` is `true` |
1166
+ | `{ stated: label }` | a highlight or `stated` item equals the label (trimmed) |
1167
+ | `{ guests: Range }`, `{ bedrooms: Range }` | the count is inside `{ min?, max? }` (inclusive) |
1168
+ | `{ rentalType: text }` | the PMS rental type equals the text |
1169
+ | `{ rating: { min?, minReviews? } }` | a real rating meets both |
1170
+ | `{ location: [...] }`, `{ region: [...] }`, `{ ids: [...] }` | the location, a region or the id is listed |
1171
+ | `{ number: { key, min?, max? } }` | `numbers[key]` is inside the range |
1172
+
1173
+ ## Showcase selectors
1174
+
1175
+ Module `showcase`. Which rentals a showcase row shows, declared as plain data in the site's data
1176
+ file and resolved at build.
1177
+
1178
+ ### `selectRentals(candidates, spec?)`
1179
+
1180
+ → `Selection` (`rentals`, `total` before `limit`: the honest "Alle {N}"). Steps: qualify (`match`,
1181
+ `exclude`, `minImages`) → `order` → `spreadBy` → `pin` leads → gates (`maxShare`, `failBelow`,
1182
+ `minSize`) → `limit`.
1183
+
1184
+ | `SelectionSpec` | default | meaning |
1185
+ | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
1186
+ | `match` | everyone | a `CardMatch` |
1187
+ | `pin` | none | ids that lead in this order, matching or not; an unknown id throws (a typo fails the build) |
1188
+ | `fill` | `true` | `false` = the pins only |
1189
+ | `exclude` | none | ids never shown (the page's own rental) |
1190
+ | `order` | `"feed"` | `ShowcaseOrder`: `feed`, `name`, `rating`, `reviews`, `guests`, `shuffle` (with `seed`: a build date gives "random per build") |
1191
+ | `spreadBy` | none | `"location"` or `"street"`: round-robin across towns or buildings |
1192
+ | `minImages` | 1 | images a rental needs |
1193
+ | `limit` | all | rentals kept |
1194
+ | `minSize` | 1 | fewer → an empty selection (the section self-hides); carousel rows use 3 |
1195
+ | `maxShare` | no check | a match holding more than this share of the candidates says nothing → empty |
1196
+ | `failBelow` | never | fewer throws at build, with the counts (a section the customer requires) |
1197
+
1198
+ ### Rows, related rentals, rotation
1199
+
1200
+ - `buildShowcaseGroups(candidates, specs, { distinct? })`: `ShowcaseGroupSpec[]` (`id`, `label`,
1201
+ `shortLabel?`, `icon?`, `href?`, `blurb?`, `select`) → `ShowcaseGroup[]`, the plain-JSON rows a
1202
+ showcase renders; an empty row is omitted. `distinct`: a rental shown in an earlier row is not
1203
+ offered to later rows
1204
+ - `selectRelated(candidates, currentId, options?)`: "more rentals nearby" for a rental page: the
1205
+ current one excluded, near ones first. `RelatedOptions`: `prefer` `"location"` (default) or
1206
+ `"region"`, `limit` 8, `minSize` 2 (fewer → `[]`), `minImages` 1
1207
+ - `rotationWindow(count, options)`: `RotationOptions` (`visible`, `minutes`, `now`, `seed`). The
1208
+ indices to show now in a long row that rotates in the browser: every tile in each round of ⌈count / visible⌉ windows (a short last window refills from the round's first tiles), the same
1209
+ window for every visitor, a new shuffle each round. Throws for `minutes <= 0`
1210
+ - `seededShuffle(items, seed)`: a deterministic Fisher–Yates
1211
+ - `resolveSearchGroups(sdk, specs, { locale, knownIds })`: build time: rows defined by curated ids
1212
+ and/or a dateless filter query (`SearchGroupSpec`: `id`, `rentalIds?`, `filterQuery?`,
1213
+ `pinCurated?`, `limit` 6) → `ResolvedSearchGroup[]` (`rentalIds`, `unusedFilterKeys`). One walk
1214
+ per query; an incomplete walk rejects (the build fails and the previous build stays live)
1215
+
1216
+ ```ts
1217
+ import { buildShowcaseGroups, type ShowcaseGroupSpec } from "@v-office/website-headless";
1218
+
1219
+ const ROWS: ShowcaseGroupSpec[] = [
1220
+ {
1221
+ id: "dogs",
1222
+ label: "Mit Hund",
1223
+ select: {
1224
+ match: { flag: "petsAllowed" },
1225
+ order: "rating",
1226
+ minSize: 3,
1227
+ maxShare: 0.8,
1228
+ limit: 12,
1229
+ },
1230
+ },
1231
+ {
1232
+ id: "sauna",
1233
+ label: "Mit Sauna",
1234
+ select: { match: { anyOf: [{ stated: "Sauna" }, { flag: "sauna" }] }, spreadBy: "location" },
1235
+ },
1236
+ ];
1237
+ const groups = buildShowcaseGroups(sources, ROWS); // sources: CardSource[] from cardSource()
1238
+ ```
1239
+
1240
+ ## Quote flow and availability
1241
+
1242
+ Module `quote`. Everything the rental page's booking widget does, as one store: availability, the
1243
+ range, the party, the URL handover, the auto-quote, cancellation-policy branches, rate faults and
1244
+ the call to action. It never books: no `book()`, no `pay()`.
1245
+
1246
+ ### `createQuoteFlow(source, options)`
1247
+
1248
+ `options` is a `QuoteFlowOptions` (the `StayPickerOptions` plus the widget's `QuoteFlowSettings`);
1249
+ returns a `QuoteFlow`.
1250
+
1251
+ | option | default | meaning |
1252
+ | -------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1253
+ | `rentalId`, `locale` | required | the PMS rental id; the language of the PMS strings |
1254
+ | `guests` | `{}` | `GuestOptions` (limits, child-age band, `defaultAdults` 2) |
1255
+ | `policyChoice` | `"rental-page"` | `PolicyChoiceMode`: where the guest picks the rate. `"rental-page"`: this widget offers the picker; `"checkout"`: it states the pre-selected rate's terms only |
1256
+ | `campaignLabels` | `"none"` | `CampaignLabelSource`. `"from-search"`: show the `?offer=` labels the search measured, latched to the first complete stay that is provably the one the search priced; dropped on any edit |
1257
+ | `whenUnavailable` | `"none"` | `WhenUnavailable`. `"next-arrival"`: offer the first arrivable day after the refused start (no extra request) |
1258
+ | `minStayNights` | none | the site's minimum stay on top of the PMS's |
1259
+ | `caps` | none | `{ maxGuests?, petsAllowed? }` from the catalogue (v9 availability before SDK 2.28 states no caps) |
1260
+ | `horizonMonths` | 12 | navigable months; availability is fetched one month further |
1261
+ | `autoQuoteDelayMs` | 350 | debounce before the auto-quote |
1262
+ | `onEvent` | one `console.warn` | receives `QuoteEventDetail` (`rate-fault`) once per stay |
1263
+ | `now` | `() => new Date()` | the clock |
1264
+
1265
+ Lifecycle: create → `configure(settings)` if the flow already existed → `hydrate(handover)` once →
1266
+ `start()` (fetches availability; nothing is quoted before the arrival map answered).
1267
+
1268
+ `QuoteFlowState`: `resolved` (the handover is applied: show skeletons until then), `picker`
1269
+ (`StayPickerState`), `party`, `bounds`, `agesComplete`, `stayKey`, `outcome`, `cta`,
1270
+ `chosenPolicyId` (null = the PMS pre-selection), `nextArrival`, `focusRequest` (bump: bring the
1271
+ widget into view).
1272
+
1273
+ | `QuoteOutcome` | meaning |
1274
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1275
+ | `idle` | no complete range, no adult, an age missing, or the handover still resolving |
1276
+ | `pending` | debounce, the arrival map, the request, or the chosen rate's branch in flight |
1277
+ | `priced` | `price` (`PriceView`), `policies` (`PolicyOffer`), `campaignLabels`, `insurance` (for custom UIs; the quote never renders it) |
1278
+ | `unavailable` | `reason`: the SDK's, verbatim (possibly multi-line), or null |
1279
+ | `rate-fault` | the days are free but the rate setup is faulty (`code`); never say "not available" |
1280
+ | `error` | transport failure; only `retry()` refires |
1281
+
1282
+ `QuoteCta` (`cta.kind`): `resolving`, `pick-dates`, `complete-ages`, `pending`, `book` (render
1283
+ `checkoutHref`), `change-dates`, `retry`. Every non-idle outcome carries its `stayKey`, so nothing
1284
+ from a superseded stay is shown.
1285
+
1286
+ Actions: `picker` (the `StayPicker`, for `day(iso)`), `configure`, `hydrate`, `selectDay(iso)`,
1287
+ `resetDates()`, `setParty(patch)` (clamped; a dog cannot be added where pets are not allowed),
1288
+ `choosePolicy(id | null)` (`"rental-page"` only; costs no request, every branch is priced up front),
1289
+ `retry()`, `pickArrival(iso)` (from an occupancy calendar), `takeNextArrival()`, `requestFocus()`,
1290
+ `start()`, `dispose()`.
1291
+
1292
+ `acquireQuoteFlow(key, create)` → `{ flow, release() }`: one flow per rental per page across islands
1293
+ (the widget, a sticky bar, an occupancy calendar). The first caller's `create` wins; a joining
1294
+ widget calls `configure` with its settings.
1295
+
1296
+ ```ts
1297
+ import {
1298
+ acquireQuoteFlow,
1299
+ checkoutHref,
1300
+ createQuoteFlow,
1301
+ readQuoteHandover,
1302
+ writeQuoteParams,
1303
+ } from "@v-office/website-headless";
1304
+
1305
+ // shared = shareSdk(SDK), claimed once for the page's surfaces
1306
+ const { flow, release } = acquireQuoteFlow("4711", () =>
1307
+ createQuoteFlow(shared.sdk, {
1308
+ rentalId: "4711",
1309
+ locale: "de-DE",
1310
+ guests: GUESTS,
1311
+ policyChoice: POLICY_CHOICE,
1312
+ }),
1313
+ );
1314
+ flow.hydrate(readQuoteHandover(new URLSearchParams(location.search)));
1315
+ flow.start();
1316
+ flow.subscribe((state) => {
1317
+ const next = writeQuoteParams(new URLSearchParams(location.search), state);
1318
+ if (next.toString() !== location.search.replace(/^\?/, "")) {
1319
+ window.history.replaceState(window.history.state, "", `?${next.toString()}`);
1320
+ }
1321
+ const href = state.cta.kind === "book" ? checkoutHref("/buchen/haus-am-see", state) : null;
1322
+ document.querySelector("a[data-book]")?.setAttribute("href", href ?? "#");
1323
+ });
1324
+ // teardown: release();
1325
+ ```
1326
+
1327
+ ### The rental page's URL contract
1328
+
1329
+ - `readQuoteHandover(params)`: `URLSearchParams` → `QuoteHandover` (`start`, `end`, `window` (a ±
1330
+ window from a search link, resolved into a stay once the maps land), `party`, `policyId`,
1331
+ `campaignLabels`, at most four). Invalid values read as absent
1332
+ - `writeQuoteParams(current, state)`: the URL after a state change: quote keys set or deleted, the
1333
+ legacy `petsCount` and the window keys stripped once resolved, every other key kept. Before
1334
+ `resolved` it returns `current` unchanged
1335
+ - `checkoutHref(path, state, kind?)`: `path?<stay>` from state (start, end, the party with dogs as
1336
+ `pets`, `policy` only when chosen, `kind=option` for an option); null without a complete, priced
1337
+ stay
1338
+ - `stayQuery(state)`: the stay alone as a query string (an enquiry link), or null
1339
+ - `QUOTE_PARAM_KEYS`, `WINDOW_PARAM_KEYS`, `CAMPAIGN_PARAM`: the keys the rental page reads and
1340
+ writes; the ± keys resolved and stripped; `"offer"`
1341
+
1342
+ ### `createStayPicker(source, options)`
1343
+
1344
+ One rental's arrival and departure picking over the PMS availability maps, shared by the quote, the
1345
+ checkout's date editor and an occupancy calendar. `StayPickerOptions`: `rentalId`, `locale`,
1346
+ `horizonMonths` (12), `minStayNights`, `now`. Nothing is fetched before `start()`: one arrival map
1347
+ per picker, one departure map per chosen start.
1348
+
1349
+ `StayPickerState`: `availability` (`"loading" | "ready" | "error"`: never render a full calendar on
1350
+ error), `window`, `range`, `phase` (`"arrival" | "departure" | "complete"`), `departures`
1351
+ (`"idle" | "loading" | "ready"`), `caps` (`maxPersons`, `maxAdults`, `childrenAllowed`, `maxPets`; null where the SDK states none), `firstArrival`,
1352
+ `nights`, `revision` (bumped on every map change: re-read `day()`).
1353
+
1354
+ `day(iso)` → `DayState`: `iso`, `kind` (`DayKind`), `selectable`, `price` (the SDK's nightly price,
1355
+ raw: format it with `dayPriceLabel`), `reason` (the SDK's block reason), `selection`
1356
+ (`"start" | "end" | "inside" | null`).
1357
+
1358
+ | `DayKind` | meaning |
1359
+ | ------------- | ----------------------------------------------------------------------------------------------------------------- |
1360
+ | `past` | before today |
1361
+ | `booked` | a hard status on the arrival map: genuinely not bookable |
1362
+ | `unreachable` | departure phase: the stay would cross a booked night (kept apart so the struck set stays stable while picking) |
1363
+ | `restricted` | available, but a rule blocks it in this phase (no check-in, minimum stay, no check-out), or the map is not in yet |
1364
+ | `free` | selectable now |
1365
+
1366
+ Actions: `select(iso)`, `setRange(start, end | null)` (re-validated once the arrival map is in),
1367
+ `reset()`, `configure({ minStayNights })`, `start()`, `dispose()`.
1368
+
1369
+ ### Availability and day prices
1370
+
1371
+ - `fetchInitialAvailability(sdk, input)` (`AvailabilityWindowInput`: `rentalId`, `from`, `through`,
1372
+ `locale`) → `InitialAvailability` (`canStart`, `hardBlocked`, `priceByDay`, `tooltipByDay`,
1373
+ `maxPersons`, `maxAdults`, and on v9 from SDK 2.28 `childrenAllowed` and `maxPets`; null where the
1374
+ SDK states none).
1375
+ - `fetchEndAvailability(sdk, input)` (`EndAvailabilityInput`: the window plus `start`) →
1376
+ `EndAvailability` (`start`, `canEnd`, `hardBlocked`, `tooltipByDay`).
1377
+ - `dayPriceLabel(formatted, mode)` → the cell text, or null. `DayPrices`: `"rounded"` (whole units,
1378
+ half up: truncation would understate every night), `"exact"` (only a zero-cents group dropped),
1379
+ `"none"`. Cells state the nightly price, never a total.
1380
+
1381
+ ### The live quote
1382
+
1383
+ - `fetchQuote(sdk, request)`: `QuoteRequest` (`rentalId`, `period`, `occupancy` from `occupancyOf`,
1384
+ `locale`, `voucher?`) → `QuoteResult`: `{ status: "available", live, view }` or
1385
+ `{ status: "unavailable", reason, code }`. Branch only on `code` (`QuoteUnavailableCode`);
1386
+ `reason` is localized. A throw is the caller's to log
1387
+ - `adaptGuestQuote(quote)`: a `GuestQuote` → `QuoteView`: `price` (`PriceView`), `services`,
1388
+ `policies`, `voucher?`, `insurance?` (absent without a real product)
1389
+ - `PriceView`, `PriceLine`: `total`, `totalBeforeModifiers` (the PMS's pre-modifier total; null on
1390
+ v9), `lines` (`kind` `charge` / `sub` / `discount`, `label`, `amount`, `netAmount`: the column
1391
+ adds up), `offerLabels`
1392
+ - `addQuoteService`, `removeQuoteService`, `selectQuotePolicy` (`(sdk, quote, id)`),
1393
+ `clearQuoteServices(sdk, quote)`, `selectQuoteInsurance(sdk, quote, id \| null)`: selection calls
1394
+ → `QuoteSelectionResult` (`{ ok: true, quote }` or `{ ok: false, reason? }`); a throw is logged
1395
+ and becomes `ok: false`. Keep the previous quote on a refusal
1396
+ - `pricePolicyBranches(sdk, base, options?)`: every selectable policy (or
1397
+ `PolicyBranchOptions.only`) priced as its own quote, sequentially → `PolicyBranch[]` (`id`,
1398
+ `quote`, `view`); a refused branch is omitted
1399
+ - `isRateConfigurationFault(code)`, `MULTI_RATE_CODES`: `AmbiguousMultiRate` /
1400
+ `UnresolvableMultiRate`: the stay was priced but the rate setup cannot be resolved; keep booking
1401
+ disabled and say so
1402
+ - `stabilizeCardOrder(view, orderMap)`: pins the breakdown's block order across re-quotes (rows that
1403
+ jump read as flakiness)
1404
+ - `QuoteAddonService`, `QuoteCancellationPolicy`, `QuotePolicyRule`, `QuoteVoucher`,
1405
+ `QuoteInsurance`, `QuoteInsuranceOption`: the view's parts, PMS values verbatim.
1406
+ `proposedQuantity` is a proposal, never a selection
1407
+
1408
+ ### Cancellation policies
1409
+
1410
+ The backend returns rate definitions and never evaluates them against the stay; these rules do.
1411
+ Deadlines are whole days (`POLICY_DEADLINE_HAS_TIME` is false).
1412
+
1413
+ - `describePolicyOffer(input)` → `PolicyOffer` for one surface. `PolicyOfferInput`: `policies`,
1414
+ `branches` (or null), `pricesPending`, `chosenId`, `arrival`, `mode` (`PolicyChoiceMode`),
1415
+ `surface` (`"quote" | "checkout"`), `today?`. `mode` of the result: `"choice"` (at least 2 offered
1416
+ rates on the surface that picks: a picker), `"single"` (one rate there: one sentence), `"terms"`
1417
+ (the other surface: the applied rate's terms), `"none"` (no policies, every v9 hub).
1418
+ - `PolicyOption`: `id`, `title` (`PolicyTitle`: `{ kind: "own", label }` for a host's policy;
1419
+ `refundable`, `expired` or `standard` for the PMS's three built-ins, whose names read as a
1420
+ warning), `selected`, `available`, `unavailableReason`, `total` (the branch's real total, or
1421
+ null), `terms` (`PolicyTerms`: `deadline` > `prose` > `rules` > `none`, or `expired`), `notes`
1422
+ (`markUp` only without a total, `processingFee`).
1423
+ - `offeredPolicies(policies, arrival?, today?)`: beside a standard rate that can no longer refund
1424
+ anything, only rates that still can survive.
1425
+ - `freeCancellationUntil(policy, arrival, today?)`, `policyStance` (→ `PolicyStance`: `refundable`,
1426
+ `expired`, `other`), `policyTitle`, `policyTerms(policy, options)` (`PolicyTermsOptions`:
1427
+ `arrival`, `today`, `hasTotal`, `titled`), `isBuiltInPolicy`, `BUILT_IN_POLICY_LABELS`
1428
+ (`BuiltInPolicyLabels`): the single rules the offer is built from.
1429
+
1430
+ ## Checkout flow
1431
+
1432
+ Module `checkout`. The checkout from the URL handover to the paid booking, as one store: the stay
1433
+ (edited in place), the quote lifecycle, extras, rate, insurance and its document gate, voucher,
1434
+ consents, the child-age gate, `book()` and `pay()`. The live `GuestQuote` that `book()` needs is not
1435
+ serializable, so it stays in this store until then.
1436
+
1437
+ ### `createCheckoutFlow(source, options)`
1438
+
1439
+ `options` is a `CheckoutFlowOptions`; returns a `CheckoutFlow` (`Store<CheckoutState>` plus
1440
+ `CheckoutActions`, `start()` and `dispose()`).
1441
+
1442
+ | option | default | meaning |
1443
+ | -------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------- |
1444
+ | `rentalId`, `locale` | required | the PMS rental id; the PMS's language |
1445
+ | `returnPath` | required | where the payment provider returns to, relative to the site origin (the site's route) |
1446
+ | `handover` | required | `readCheckoutHandover(location.search, …)` |
1447
+ | `messages` | required | `CheckoutMessages`: `rateFault`, `unavailable`, `quoteRefreshFailed`, `voucherFailed`, `voucherRejected` |
1448
+ | `context` | none | `CheckoutContext` (`rentalName`, `location?`, `href?`, `images?`) parked in sessionStorage on `start()` for the return page |
1449
+ | `policyChoice` | `"rental-page"` | `"rental-page"` applies the carried `?policy=`; `"checkout"` makes the offered rates choosable here (`selectPolicy`) |
1450
+ | `requiredConsents` | `[]` | ids of the consent checkboxes the UI renders; each must be ticked before `book()` |
1451
+ | `bankHolderFallback` | none | the transfer account holder when the PMS sends none (the operator's name) |
1452
+ | `guests` | none | `GuestOptions` for the guest editor |
1453
+ | `minStayNights` | none | `setDates` refuses a shorter range |
1454
+ | `caps` | none | `{ maxGuests?, petsAllowed? }` from the catalogue, until `loadGuestCaps()` adds availability's |
1455
+ | `onEvent` | none | receives every `CheckoutEventDetail`; without it a rate fault is logged with `console.warn` |
1456
+
1457
+ `start()` parks the context and prices the handed-over stay (idempotent); `dispose()` drops every
1458
+ in-flight result.
1459
+
1460
+ `CheckoutPhase` (`state.phase.kind`): `loading`, `missing-stay` (no complete stay arrived), `ready`,
1461
+ `unavailable` (`reason`), `error` (`retry()`), `booked` (`confirmation` is set).
1462
+
1463
+ `CheckoutBlocker`: why the order button is off, in the order a UI should mention them. `book()` is
1464
+ refused while `blockers` is not empty.
1465
+
1466
+ | blocker | meaning |
1467
+ | --------------------- | ---------------------------------------------------------- |
1468
+ | `no-quote` | no priced quote, or the phase is not `ready` |
1469
+ | `price-pending` | the shown price does not belong to the shown stay yet |
1470
+ | `busy` | a selection or a re-quote is running |
1471
+ | `issue` | the last re-quote failed or found the stay unavailable |
1472
+ | `child-ages` | a child has no age (the age prices the stay) |
1473
+ | `insurance-documents` | a chosen insurance's document step is unfinished (§ 7 VVG) |
1474
+ | `consents` | a required consent is not ticked |
1475
+
1476
+ `CheckoutState` also carries `kind`, `stay`, `bounds`, `view`, `services` (`CheckoutService`: the
1477
+ extra plus `count`, `canAdd`, `canRemove`), `policy` (`CheckoutPolicyState`: `applied`, `offered`,
1478
+ `all`, `freeCancellationUntil`, `offer`), `insurance` (`CheckoutInsuranceState` or null: `options`,
1479
+ `noneLabel`, `chosen`, `documents`, `requiredDocuments`, `documentsPending`), `voucher`
1480
+ (`CheckoutVoucherState`: `applied`, `error`, `checking`), `consents`, `pricePending`, `busy`,
1481
+ `issue` (`CheckoutIssue`: `reason`, `retryable`), `selectionError`, `submit`
1482
+ (`"idle" | "submitting" | "failed"`), `confirmation`, `payment` (`busyOption`, `failed`).
1483
+
1484
+ | action | what it does |
1485
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
1486
+ | `setDates(start, end)`, `setParty(party)` | edit the stay; re-quotes after a short debounce (extras, rate and insurance carried over) |
1487
+ | `loadGuestCaps()` | once, when the guest editor opens: the arrival map's caps (v9: children and pets rules) bound it |
1488
+ | `changeService(id, 1 \| -1)` | one unit of an extra |
1489
+ | `selectPolicy(id)` | `policyChoice: "checkout"` only, and only to an offered rate |
1490
+ | `selectInsurance(id \| null)` | a selectable product, or none |
1491
+ | `openInsuranceDocument(url)`, `confirmInsuranceDocuments(confirmed)` | the document step: every shown document opened, then the confirmation (bound to the chosen product) |
1492
+ | `applyVoucher(code \| null)` | re-quote with a code; the previous quote stays when it is rejected |
1493
+ | `setConsent(id, value)`, `retry()` | a required consent; re-quote after a transport issue |
1494
+ | `book(contact)` | ⚠ user-initiated only: creates a real booking (`BookingContact` from the guest form) |
1495
+ | `pay(option)` | ⚠ user-initiated only: redirects to the real payment provider (not for bank transfer) |
1496
+
1497
+ ```ts
1498
+ import {
1499
+ CHECKOUT_EVENT,
1500
+ createCheckoutFlow,
1501
+ readCheckoutHandover,
1502
+ } from "@v-office/website-headless";
1503
+
1504
+ // shared = shareSdk(SDK)
1505
+ const flow = createCheckoutFlow(shared.sdk, {
1506
+ rentalId: "4711",
1507
+ locale: "de-DE",
1508
+ returnPath: "/buchen/bestaetigung",
1509
+ handover: readCheckoutHandover(location.search, { allowOption: false }),
1510
+ context: { rentalName: "Haus am See", href: "/unterkuenfte/haus-am-see" },
1511
+ requiredConsents: ["terms"],
1512
+ guests: GUESTS,
1513
+ messages: CHECKOUT_MESSAGES, // your copy: the components' CheckoutCopy satisfies it
1514
+ onEvent: (detail) => window.dispatchEvent(new CustomEvent(CHECKOUT_EVENT, { detail })),
1515
+ });
1516
+ flow.start();
1517
+ ```
1518
+
1519
+ ### Handover
1520
+
1521
+ - `readCheckoutHandover(search, options?)` (`CheckoutHandoverOptions`: `defaultAdults`,
1522
+ `allowOption`) → `CheckoutHandover`: `stay` (`Stay` or null: the missing-stay card), `policyId` (a
1523
+ seed, applied only while still offered), `kind` (`"option"` only with `allowOption`),
1524
+ `paymentReturn` (`?return=1`). Reads `pets` and the legacy `petsCount`.
1525
+ - `checkoutSearch({ stay, policyId, kind }, current?)` → the query string for `replaceState` after
1526
+ an edit; every other parameter of `current` is kept.
1527
+ - `BookingKind`: `"booking" | "option"` (a non-binding option, SDK `kind: "OPTION"`).
1528
+ - `Stay`: `{ start, end, party }`, serializable.
1529
+
1530
+ ### `createGuestForm(options)`
1531
+
1532
+ The guest data form as a store (`GuestFormOptions` → `GuestForm`), with the fleet's timing: quiet
1533
+ while typing, a present value judged on leaving the field, a shown error cleared the moment it is
1534
+ fixed, empty fields judged on submit.
1535
+
1536
+ | option | default | meaning |
1537
+ | ----------------- | -------- | ------------------------------------------------------------------------------------------------ |
1538
+ | `messages` | required | `ValidationMessages` (the checkout copy satisfies it) |
1539
+ | `countries` | required | `CountryGroups` from `countryOptions` (build) |
1540
+ | `requiredFields` | `[]` | `OptionalGuestField[]` made required on top of the minimum: `salutation`, `phone`, `housenumber` |
1541
+ | `fallbackCountry` | `"DE"` | the Land when the browser languages name none, and the dial code while no Land is chosen |
1542
+ | `languages` | `[]` | `navigator.languages`, passed in by the UI |
1543
+
1544
+ Fields (`GuestFormField`, in visual order): `salutation`, `forename`, `surname`, `email`, `phone`,
1545
+ `street`, `housenumber`, `postalcode`, `city`, `country`, `note`. `GuestFormState`: `values`,
1546
+ `errors` (shown, blocking), `warnings` (non-blocking: the street seems to carry the house number),
1547
+ `touched`, `firstInvalid`. Actions: `set(field, value)`, `blur(field)` (normalizes the phone in
1548
+ place), `submit()` → `BookingContact | null` (null with every error shown and `firstInvalid` set),
1549
+ `dispose()`.
1550
+
1551
+ ```ts
1552
+ import { createGuestForm } from "@v-office/website-headless";
1553
+
1554
+ const guestForm = createGuestForm({
1555
+ messages: VALIDATION_MESSAGES,
1556
+ countries: COUNTRIES,
1557
+ languages: navigator.languages,
1558
+ });
1559
+ const contact = guestForm.submit();
1560
+ if (contact && flow.getState().blockers.length === 0) flow.book(contact); // the guest pressed the order button
1561
+ ```
1562
+
1563
+ ### Booking and payment
1564
+
1565
+ The checkout flow calls these; a custom checkout built without the flow calls them directly.
1566
+
1567
+ - `submitBooking(sdk, quote, contact, options)` → `BookingConfirmation` (`kind`, `bookingNumber`,
1568
+ `schedules`, `alternatives` (settle the whole balance at once: an alternative, never added),
1569
+ `insurancePayments`, `payable`). ⚠ Reserves for real. `SubmitBookingOptions`: `returnPath`,
1570
+ `kind`, `bankHolderFallback`, `stay`. Writes the payment-return snapshot the moment the booking
1571
+ number exists. Logs the SDK's cause and rethrows.
1572
+ - `PaymentScheduleView`: `kind`, `label`, `dueOn` (a complete sentence: render, never parse),
1573
+ `paymentAt`, `lines` (`PaymentLine`), `amount`, `options` (empty = informational).
1574
+ - `isSubmittableOption(option)`: every kind but `bank_transfer`.
1575
+ - `payWithOption(sdk, option)`: ⚠ redirects or form-posts to the provider; by then the reservation
1576
+ exists, so a failure is logged before it is rethrown.
1577
+ - `InsurancePaymentLink`: a premium owed to the insurer (`label`, `url`, `description?`), never part
1578
+ of the schedules.
1579
+
1580
+ ### Insurance and the document gate
1581
+
1582
+ Not configurable per site: these rules gate a real insurance contract (§ 7 VVG: the documents before
1583
+ the guest's declaration).
1584
+
1585
+ - `selectableInsuranceOptions(insurance)`: products the checkout can complete (no per-traveler data,
1586
+ at least one shown pre-contractual document).
1587
+ - `shownInsuranceResources(option)`: the documents listed and gated (every shown document, never a
1588
+ label-picked subset).
1589
+ - `insuranceDocsPending(option, docs)`: every shown document opened and the product confirmed; no
1590
+ plan chosen never blocks. `InsuranceDocsState` (`opened`, `confirmedProduct`),
1591
+ `NO_INSURANCE_DOCS`.
1592
+ - `selectedInsuranceOption`, `insuranceProductKey` (the product label: survives a trip-value band
1593
+ change), `insuranceIdToReapply(fresh, wanted)` (the plan to re-apply after a re-quote).
1594
+
1595
+ ### Payment return
1596
+
1597
+ The return URL cannot carry the booking number, so a snapshot this tab wrote (sessionStorage, only
1598
+ what the page renders, no personal data) is what the return page shows.
1599
+
1600
+ - `readPaymentReturn(search, storage?)` → `PaymentReturn`, by `state`: `"booked"` (this tab booked:
1601
+ `snapshot` and `paymentCancelled`), `"returned"` (a provider return without a snapshot:
1602
+ `paymentCancelled`), or `"none"` (a bare or tracking-only URL). `paymentCancelled` is the
1603
+ provider's cancel marker (`payment=stripe&cancel=true`).
1604
+ - `BookingSnapshot`: `bookingNumber`, `kind`, `rentalName?`, `location?`, `href?`, `images?`
1605
+ (`SnapshotImage`, at most five), `start?`, `end?`, `guests?`, `total?`.
1606
+ - `saveCheckoutContext(context, storage?)`: the flow calls it on `start()`. Never throws.
1607
+ - `CHECKOUT_CONTEXT_KEY`, `BOOKING_SNAPSHOT_KEY`: the sessionStorage keys.
1608
+
1609
+ ### Structure: `assertCheckoutStructure(root)`
1610
+
1611
+ Headless holds the rules but no markup, so a custom checkout could drop a legal obligation while
1612
+ every unit test passes. `assertCheckoutStructure(root)` is the contract on the rendered result,
1613
+ whoever wrote it: a pure DOM check (a browser `document`, or a jsdom document in a test) that
1614
+ returns every `StructureViolation` (`requirement`: a `CheckoutRequirement`, `basis`, `detail`);
1615
+ empty = compliant. Everything is judged relative to the one order button, in DOM order. It is not
1616
+ run at publish time: a custom checkout is a review item.
1617
+
1618
+ | `CHECKOUT_STRUCTURE` (`CheckoutStructure`: a `StructureRequirement` per id) | basis | check |
1619
+ | --------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------- |
1620
+ | `submit-present` | § 312j Abs. 3 BGB | exactly one `data-part="checkout-submit"` |
1621
+ | `submit-wording` | § 312j Abs. 3 BGB | its visible text matches `SUBMIT_WORDING` for its `data-booking-kind` |
1622
+ | `total-before-submit` | § 312j Abs. 2 BGB | a `checkout-price-total` precedes it |
1623
+ | `terms-before-submit` | § 312d BGB / Art. 13 DSGVO | a `checkout-terms` precedes it |
1624
+ | `consents-before-submit` | § 312d BGB / Art. 13 DSGVO | every `checkout-consent` precedes it |
1625
+ | `insurance-docs-before-confirm` | § 7 Abs. 1 VVG, § 4 VVG-InfoV | with an insurance confirmation: every document precedes every confirmation |
1626
+ | `premium-before-submit` | § 312j Abs. 2 BGB | with an insurance confirmation: a `checkout-insurance-premium` precedes the button |
1627
+
1628
+ `SUBMIT_WORDING` (`SubmitWording`, a pattern per `BookingKind`): booking
1629
+ "(zahlungspflichtig|kostenpflichtig) (buchen|bestellen|reservieren)", "jetzt kaufen", "book with
1630
+ obligation to pay", "book and pay", "pay now", "buy now"; option "unverbindlich
1631
+ (reservieren|anfragen)", "reserve without obligation", "non-binding reservation". A site may
1632
+ re-voice the label within its class; the English and option classes await a legal review. The parts
1633
+ are listed under [Part vocabularies](#checkout_parts).
1634
+
1635
+ ```ts
1636
+ import { assertCheckoutStructure } from "@v-office/website-headless";
1637
+
1638
+ const violations = assertCheckoutStructure(document);
1639
+ for (const violation of violations)
1640
+ console.warn(`${violation.requirement} (${violation.basis}): ${violation.detail}`);
1641
+ ```
1642
+
1643
+ ## Contact form
1644
+
1645
+ Module `forms`. The contact / enquiry form as a store. A submit posts a real request into the PMS
1646
+ (not idempotent, not reversible). The field set is the site's; how a field behaves is the package's.
1647
+
1648
+ ### `createContactForm(source, options)`
1649
+
1650
+ `options` is a `ContactFormOptions`; returns a `ContactForm` (`Store<ContactFormState>` plus
1651
+ `ContactFormActions`). The SDK source may be lazy (`loadSharedSdk`): it is awaited on the first
1652
+ submit only, so a contact page ships no SDK until someone sends a message.
1653
+
1654
+ | option | default | meaning |
1655
+ | --------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
1656
+ | `locale` | required | the request's locale |
1657
+ | `subject` | required | recorded on the request in the PMS; names the channel ("Anfrage Website"); at most 200 characters |
1658
+ | `fields` | required | `ContactFieldSpec[]` in render order (`DEFAULT_CONTACT_FIELDS`: salutation, forename, surname, email, phone, country, message) |
1659
+ | `messages` | required | `ContactFormMessages` (labels, required and invalid messages, the failure lines, the message-line words) |
1660
+ | `supportEmail` | required | named in the failure line; `null` drops that half (no publishable inbox). No default: it would print one agency's inbox on another's site |
1661
+ | `initialValues` | none | laid over the fields' defaults (prefill, a detected country) |
1662
+ | `now` | `() => new Date()` | the clock for "today" constraints and periods |
1663
+
1664
+ `ContactFormState`: `items` (`ResolvedContactItem[]`: what validation, payload and render all walk;
1665
+ `ResolvedContactField` is its field member, with a `ContactControl`), `values` (`ContactValues`: a
1666
+ `ContactValue` per name: a string, a boolean or a `PeriodValue` `{ from, to, flexible }`), `errors`
1667
+ (field name → message, in list order), `phase` (`ContactPhase`: `editing`, `submitting`, `sent`),
1668
+ `submitError` (the line above the button), `focusTarget` (`{ name, nonce }`: focus once per nonce),
1669
+ `specProblems` (report once with `console.warn`; never thrown).
1670
+
1671
+ Actions: `setValue(name, value)`, `blur(name)` (judges a present email), `submit()` →
1672
+ `ContactSubmitOutcome` (`{ ok: true }`, or `ok: false` with a `reason`: `busy`, `invalid`,
1673
+ `rejected` (the SDK refused the input; its issues are routed onto the fields) or `failed`; ⚠
1674
+ user-initiated only), `reset()` ("send another message"), `update(options)` (`messages`, `locale`,
1675
+ `subject`, `supportEmail`; values are kept), `dispose()`.
1676
+
1677
+ ### Field specs
1678
+
1679
+ A `ContactFieldSpec` is one of:
1680
+
1681
+ - `"email"`: a built-in (`ContactFieldName`: `salutation`, `forename`, `surname`, `email`, `phone`,
1682
+ `country`, `street`, `postalCode`, `city`, `message`) as it ships;
1683
+ - `{ field: "phone", required: true }`: a built-in with decisions changed (`BuiltInFieldSpec`:
1684
+ `required`, `label`, `width` (`FieldWidth`: `"full"` or `"half"`), `options` (salutation only),
1685
+ `defaultValue`, plus `ValueConstraints`);
1686
+ - `{ extra: "persons", kind: "number", label: "Personen" }`: a field the API has no slot for
1687
+ (`ExtraFieldSpec`); its answer becomes one line of the message;
1688
+ - `{ group: "Ihre Anfrage" }`: an inert heading over the fields that follow.
1689
+
1690
+ Built-ins required by default: salutation, forename, surname, email, country, message (phone,
1691
+ street, postalCode and city are optional). The endpoint rejects an empty `salutation`, `surname`,
1692
+ `email`, `country` or `message` (`CONTACT_ENDPOINT_REQUIRED`); `contactSpecProblems(fields)` reports
1693
+ a spec that would make every submit fail.
1694
+
1695
+ | extra `kind` | message line |
1696
+ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
1697
+ | `text` (default), `multiline`, `email`, `tel`, `number` | `<prefix>: <value>`, only when answered |
1698
+ | `date` | `<prefix>: <formatIsoDate(value)>` |
1699
+ | `select` (with `options: SelectOption[]`) | `<prefix>: <option label>` |
1700
+ | `checkbox` | `<prefix>: <checkboxYes \| checkboxNo>`, always written |
1701
+ | `period` (with `allowFlexible?`) | `<prefix>: <from> <periodSeparator> <to>` (an open end is `periodOpen`), or `<periodFlexible>` |
1702
+
1703
+ Extras take `extra` (`[a-z][a-zA-Z0-9]*`, not a built-in name), `label`, `required?`,
1704
+ `messagePrefix?` (default the label), `placeholder?`, `width?`, `defaultValue?`, `prefillParam?`
1705
+ (text, number, date and select only), and `ValueConstraints`: `min`/`max` (numbers, or ISO days and
1706
+ `"today"` for a date), `step`, `pattern` (the whole value, `v` flag), `maxLength`, `invalidMessage`.
1707
+ Extras listed before the `message` field form a block above the free text, extras after it a block
1708
+ below; the composed message may not exceed `CONTACT_LIMITS.message` (5000).
1709
+
1710
+ Built-in fields are never filled from a URL: they are personal data, and a URL is logged and shared.
1711
+
1712
+ ```ts
1713
+ import {
1714
+ createContactForm,
1715
+ loadSharedSdk,
1716
+ prefillFromParams,
1717
+ resolveContactFields,
1718
+ type ContactFieldSpec,
1719
+ } from "@v-office/website-headless";
1720
+
1721
+ const FIELDS: ContactFieldSpec[] = [
1722
+ "salutation",
1723
+ "forename",
1724
+ "surname",
1725
+ "email",
1726
+ { field: "phone", required: true },
1727
+ "country",
1728
+ { group: "Ihre Anfrage" },
1729
+ { extra: "stay", kind: "period", label: "Reisezeitraum", allowFlexible: true },
1730
+ { extra: "persons", kind: "number", label: "Personen", min: 1, max: 12, prefillParam: "adults" },
1731
+ "message",
1732
+ ];
1733
+ const prefill = prefillFromParams(
1734
+ resolveContactFields(FIELDS, CONTACT_MESSAGES),
1735
+ new URLSearchParams(location.search),
1736
+ );
1737
+ const form = createContactForm(async () => (await loadSharedSdk(SDK)).sdk, {
1738
+ locale: "de-DE",
1739
+ subject: "Anfrage Website",
1740
+ fields: FIELDS,
1741
+ messages: CONTACT_MESSAGES, // your copy: the components' ContactFormCopy satisfies it
1742
+ supportEmail: "info@example.com",
1743
+ initialValues: { ...prefill, country: "DE" },
1744
+ });
1745
+ ```
1746
+
1747
+ ### The pure rules
1748
+
1749
+ The store runs these; a custom UI may call them directly.
1750
+
1751
+ - `resolveContactFields(fields, messages)`: specs → `ResolvedContactItem[]` (control, label,
1752
+ required, width, autocomplete token, constraints, prefill parameter)
1753
+ - `validateContactValues(items, values, messages, { locale, subject, today })`: field name →
1754
+ message, in list order; empty when the values can be sent
1755
+ - `buildContactInput(items, values, { locale, subject, messages })`: the `live.contact.submit` input
1756
+ (the phone sent as typed)
1757
+ - `prefillFromParams(items, params)`: `prefillParam` values from the URL: trimmed, at most 200
1758
+ characters, a number must read as one, a date must be an ISO day, a select value one of its
1759
+ options
1760
+ - `routeContactIssues(error, items, messages, supportEmail)`: a rejected submit's
1761
+ `error.cause.issues` onto the fields → `RoutedContactIssues` (`errors`, `submitError`, `reason`)
1762
+ - `contactSpecProblems(fields)`, `CONTACT_ENDPOINT_REQUIRED`, `CONTACT_LIMITS` (`message` 5000,
1763
+ `subject` 200), `DEFAULT_CONTACT_FIELDS`: the spec check and its inputs
1764
+
1765
+ ### Field validation (shared with the guest form)
1766
+
1767
+ | export | rule |
1768
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1769
+ | `emailError(raw, messages)` | required, and shaped like an address with a dotted top-level domain (the endpoint stays the authority) |
1770
+ | `normalizePhone(raw, dial)` | separators → spaces, `00CC` → `+CC`, a trunk `0` → `+<dial>`; null for blank; without `dial` a national number stays as typed |
1771
+ | `phoneError(raw, dial, messages)` | blank is valid; present, it must normalize to `+<1-9><6–14 digits>` (`phoneInvalid`), else `phoneNeedsDialCode` |
1772
+ | `postalcodeError(raw, country, messages)` | required; a format check only for DE (5 digits), AT and CH (4); every other country accepted as typed |
1773
+ | `streetWarning(street, housenumber, messages)` | non-blocking: the street ends in a number that is probably the house number |
1774
+ | `Salutation`, `SALUTATION_WIRE_VALUES` | `female` / `male` / `unspecified` → `Frau` / `Herr` / `Keine Angabe` (one vocabulary in the PMS whatever the language) |
1775
+ | `ValidationMessages` | every sentence these rules return |
1776
+
1777
+ ## Countries
1778
+
1779
+ Module `countries`. The register for "Land" selects and phone prefixes. Labels come from `Intl` at
1780
+ build time; the result is plain data passed to islands.
1781
+
1782
+ - `countryOptions(input)` (`CountryOptionsInput`: `locale`, `featured`) → `CountryGroups`
1783
+ (`featured`, `all`, each `CountryOption = [code, label]`). `featured` defaults to
1784
+ `DEFAULT_FEATURED_COUNTRIES` (`["DE", "AT", "CH"]`); a site adds its audience or reorders. A
1785
+ featured code appears only in `featured`. Call it at build time.
1786
+ - `dialCode(code)` → the E.164 calling code (digits) for Europe and the common overseas origins, or
1787
+ `undefined`: never a guessed prefix.
1788
+ - `detectCountry(countries, languages, fallback = "DE")`: preselect from `navigator.languages`;
1789
+ non-English tags first, then English tags with an explicit region; bare `"en"` is never maximized.
1790
+ - `CountryCode`: ISO 3166-1 alpha-2; the register is never filtered.
1791
+
1792
+ ## Legal documents
1793
+
1794
+ Module `legal`. Build time only.
1795
+
1796
+ `fetchLegalDocuments(sdk, locale)` → `LegalDocuments` (`terms`, `privacy`: each a `LegalDocument`
1797
+ with `title` and sanitized `blocks: RichBlock[]`, or null when the workspace publishes none). v10
1798
+ reads `static.documents.getTermsAndPrivacyPolicy` (a leading headline repeating the title is
1799
+ dropped: the page draws its own). v9 returns both null without a call until the SDK serves the hub's
1800
+ documents; a v9 site renders its own terms text through `parseTextBlocks` or
1801
+ `normalizeStructuredDocument`. It throws on transport, logged with the SDK's cause: a build never
1802
+ silently ships a checkout whose terms it could not read.
1803
+
1804
+ ## Favourites
1805
+
1806
+ Module `favorites`. The Merkliste: saved rental ids, persisted per site under the kits' key, so no
1807
+ guest loses a list when a site switches.
1808
+
1809
+ - `favoritesStorageKey(namespace)` → `` `${namespace}-merkliste` ``. The namespace is required and
1810
+ non-empty (it throws otherwise): pass the exact namespace the site's code resolves today, fallback
1811
+ included. A changed key empties every guest's list.
1812
+ - `createFavorites(options)` → `Favorites`, a store the caller owns. `FavoritesOptions`:
1813
+ `storageKey`, `storage` (default `browserStorage("local")`; null = in memory; writes fail soft),
1814
+ `onChange` (every change, once per id).
1815
+ - `acquireFavorites(options)` → `{ favorites, release() }`: the page's shared Merkliste for the key,
1816
+ so the `./enhance` binder, a favourites page and every heart see one set. The first claim's
1817
+ storage wins; every claim's `onChange` receives the changes until released.
1818
+ - `Favorites` (`Store<FavoritesState>` with `ids: ReadonlySet<string>`): `ids()`, `has(id)`,
1819
+ `toggle(id)` → the new saved state, `set(id, saved)`, `prune(known)` (drop ids the site no longer
1820
+ knows), `reload()` (another tab changed it), `dispose()`. It satisfies the search store's
1821
+ `FavoritesPort`.
1822
+ - `FAVORITES_EVENT` (`"merkliste:changed"`, detail `FavoritesChange`: `{ id, saved }`): dispatched
1823
+ on `window` by `bindFavorites`.
1824
+
1825
+ ```ts
1826
+ import { acquireFavorites, favoritesStorageKey } from "@v-office/website-headless";
1827
+
1828
+ export const FAVORITES_KEY = favoritesStorageKey("ferienhaus"); // the site's own namespace, byte for byte
1829
+
1830
+ const { favorites, release } = acquireFavorites({ storageKey: FAVORITES_KEY });
1831
+ favorites.toggle("4711");
1832
+ // teardown: release();
1833
+ ```
1834
+
1835
+ ## Shared helpers
1836
+
1837
+ Module `shared`.
1838
+
1839
+ ### Dates and text
1840
+
1841
+ | export | what it does |
1842
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
1843
+ | `IsoDay`, `IsoMonth` | `YYYY-MM-DD`, `YYYY-MM` (the URL spells a month `MM-YYYY`) |
1844
+ | `isIsoDay(value)` | a real calendar day ("2026-02-30" is not) |
1845
+ | `todayIso(now?)` | today in the visitor's timezone |
1846
+ | `addDays(day, days)`, `nightsBetween(start, end)` | calendar arithmetic without a DST shift; throw `RangeError` for a non-ISO day |
1847
+ | `formatIsoDate(day, locale)` | `"01.09.2026"` (de-DE), `"09/01/2026"` (en-US); non-ISO input returned unchanged |
1848
+ | `fillTemplate(template, values)` | replaces `{name}` tokens; an unknown token stays visible (`"{nights}"`), so a gap shows in review |
1849
+
1850
+ ### Copy templates
1851
+
1852
+ A copy string that takes tokens is typed `Template<"count" | "label">`: the tokens the code fills, none of
1853
+ them required. It is only a type, so any string literal is assignable and a table stays plain JSON.
1854
+
1855
+ - `fillTemplate` needs a value for every token a `Template<K>` declares and takes no other; a plain
1856
+ `string` takes any values. A key and the code that fills it cannot drift apart.
1857
+ - `defineCopy<T>()(overrides)` checks a site's overrides of a copy interface `T` (a component's
1858
+ `XCopy`, or `SharedCopyOverrides` one level deep): an unknown key, a wrong type and a token its key
1859
+ does not declare are compile errors (`"{cuont} is not a token of this string"`). It returns the
1860
+ overrides unchanged; the props take them as they are. Write a site's overrides through it, so a typo
1861
+ fails `astro check` instead of showing `{cuont}` on the live page:
1862
+
1863
+ ```ts
1864
+ import { defineCopy } from "@v-office/website-headless";
1865
+ import type { ShowcaseCopy } from "@v-office/website-components";
1866
+
1867
+ export const SHOWCASE_COPY = defineCopy<ShowcaseCopy>()({ moreCount: "Alle {count} {label}" });
1868
+ ```
1869
+
1870
+ - `TokensOf<S>` (the tokens a literal carries) and `CheckedCopy<T, O>` (the check itself) are exported
1871
+ for a site's own tables. A brace pair in copy always reads as a token, as it does for `fillTemplate`.
1872
+
1873
+ ### Prices
1874
+
1875
+ `formatPriceLabel(label, cents)` trims the SDK's formatted money string without recomputing it, in
1876
+ any locale's separators (`"1.505,50 €"`, `"1,505.50 €"`, `"CHF 1'234.50"`); a shape it does not
1877
+ recognise is returned unchanged. `PriceCents`:
1878
+
1879
+ | mode | `"2.022,50 €"` becomes | rule |
1880
+ | ----------- | ---------------------- | ------------------------------------------------------------------------- |
1881
+ | `show` | `2.022,50 €` | verbatim |
1882
+ | `hide-zero` | `2.022,50 €` | drop a zero cents group only (`"1.234,00 €"` → `"1.234 €"`) |
1883
+ | `truncate` | `2.022 €` | cut the cents, never round up (a card never shows more than the checkout) |
1884
+ | `round` | `2.023 €` | half up to whole units |
1885
+
1886
+ `parsePriceLabel(label)` → a number for ordering only, or null (never 0: 0 would sort first).
1887
+
1888
+ ### Rich text
1889
+
1890
+ PMS text as sanitized data. Headless never renders markup: these return a block tree; the
1891
+ components' `RichText` (or a site's own markup) renders it and escapes every text.
1892
+
1893
+ - `RichBlock`: `heading` (level 2–4), `paragraph`, `list` (`ordered`, `items`), `rule`.
1894
+ `RichInline`: `text` (with `strong`, `em`, `code`), `break`, `link` (`href` already whitelisted:
1895
+ https, http, mailto, tel, `/path`, `#anchor`; `external` for http(s)).
1896
+ - `parseTextBlocks(text, markup = "light")`: PMS plain text → blocks; blank lines separate
1897
+ paragraphs. `TextMarkup` `"light"`: `**bold**` → strong, `- ` / `* ` lines → a list.
1898
+ - `normalizeStructuredDocument(nodes, options?)` (`StructuredDocumentOptions`: `title`): the SDK's
1899
+ structured-document nodes → blocks; editor markers and context-bound tools dropped, a leading
1900
+ heading repeating `title` removed.
1901
+ - `plainText(blocks, options?)` (`PlainTextOptions`: `max`): plain text for a meta description or an
1902
+ alt text, cut at a word boundary.
1903
+
1904
+ ### Links, tracking, storage
1905
+
1906
+ - `routeHref(template, params)`: `RouteParams` (`slug`, `id`): fills a `RouteTemplate`
1907
+ (`"/unterkuenfte/{slug}"`, `"/objekt/{id}/"`). Throws when a token stays unfilled, is empty or
1908
+ unknown: a wrong link fails the build
1909
+ - `TRACKING_PARAM_PATTERN`, `isTrackingParam(key)`: the one tracking list: `utm_*`, `mtm_*`, `pk_*`,
1910
+ `gad_*`, `gclid`, `gbraid`, `wbraid`, `dclid`, `fbclid`, `msclkid`, `mc_eid`, `ref`, `_gl`
1911
+ - `withoutTracking(search)`: the query without tracking parameters (no `?`)
1912
+ - `shareUrl(location, options?)`: `ShareLocation` (`pathname`, `search`, `origin`) and
1913
+ `ShareUrlOptions` (`origin`, `strip`): canonical origin + path + query without tracking (and
1914
+ `strip`) parameters, no hash
1915
+ - `StorageLike`, `browserStorage(kind)`: Web Storage when present and writable (`"local"`,
1916
+ `"session"`), else null (SSR, private mode, a sandboxed frame)
1917
+ - `ContactLink`: `{ label, href }` (`tel:`, `mailto:` or a page)
1918
+ - `BookableFrom`: `{ from, noticeUntil?, contact?, precision? }`: not bookable online before `from`
1919
+ (`precision`: whether the notice names the day, the month or the year; presentation only). The search
1920
+ form floors its pickers at `from`; the quote opens its calendar there and does not floor (the PMS
1921
+ stays the arbiter); the notice shows while today is before `noticeUntil ?? from`
1922
+
1923
+ ### Glyphs
1924
+
1925
+ `Glyph` is a closed vocabulary (`GLYPHS`, a `GlyphList`, lists every name): facts and rooms
1926
+ (`users`, `bed`, `bath`, `ruler`, `maximize`, `sofa`, `house`, `door`), places (`waves`, `sailboat`,
1927
+ `footprints`, `mountain`, `building`, `tractor`, `pin`), card seals (`paw`, `flame`, `droplets`,
1928
+ `trees`, `sun`, `armchair`, `home`, `key`, `star`) and amenities (`check`, `view`, `terrace`,
1929
+ `balcony`, `garden`, `grill`, `fireplace`, `pool`, `sauna`, `jetty`, `kitchen`, `oven`, `microwave`,
1930
+ `fridge`, `freezer`, `coffee`, `kettle`, `dishwasher`, `washer`, `dryer`, `heating`, `wifi`, `tv`,
1931
+ `elevator`, `parking`, `garage`, `children`, `nonSmoking`, `groups`, `noGroups`, `pets`, `beach`). A
1932
+ glyph may never carry a meaning its label does not; components map each name to an icon.
1933
+
1934
+ ## Enhance engines
1935
+
1936
+ Entry `@v-office/website-headless/enhance`, browser only. Engines that bind server-rendered markup
1937
+ they do not own: strips, dialog layers, the rental gallery, reveal and clamp, share, image fade, the
1938
+ favourites binder and the MapLibre engines. They find what they bind by `data-part`
1939
+ ([`ENHANCE_PARTS`](#enhance_parts)) and write state attributes only, never a class. Not React,
1940
+ because the description and amenities must be in the HTML, the gallery hero is the LCP element, and
1941
+ one card strip markup is rendered by Astro and by React islands alike.
1942
+
1943
+ ### `enhance(root = document, options?)`
1944
+
1945
+ Binds every engine below `root`, once, from the layout (never per section). Idempotent: an element
1946
+ already bound (by an earlier call or a hydrated component) stays its first binder's; a second call
1947
+ binds only what is new, and its `Dispose` unbinds only that.
1948
+
1949
+ | `EnhanceOptions` | default | binds |
1950
+ | ---------------- | ------- | ----------------------------------------------------------------------------- |
1951
+ | `strips` | `true` | `[data-part="strip"]` and legacy `.card-images`, when the browser is idle |
1952
+ | `galleries` | `true` | `[data-part="rental-gallery"]` with its strips and layers |
1953
+ | `layers` | `true` | `<dialog data-part="layer">` |
1954
+ | `reveals` | `true` | `[data-part="reveal"]` "show more" lists |
1955
+ | `clamps` | `true` | `[data-part="clamp"]` line-clamped texts |
1956
+ | `share` | `true` | `[data-part="share"]` / `[data-share]` buttons; an object sets `ShareOptions` |
1957
+ | `imageFade` | `true` | the lazy-photo fade inside every gallery |
1958
+ | `favorites` | off | `{ storageKey }` binds the hearts and badges; without it they are not bound |
1959
+
1960
+ ```ts
1961
+ import { enhance } from "@v-office/website-headless/enhance";
1962
+
1963
+ // In the layout. With Astro's ClientRouter: bind on page-load, dispose before the swap.
1964
+ let dispose = () => {};
1965
+ document.addEventListener("astro:page-load", () => {
1966
+ dispose = enhance(document, { favorites: { storageKey: FAVORITES_KEY } });
1967
+ });
1968
+ document.addEventListener("astro:before-swap", () => dispose());
1969
+ ```
1970
+
1971
+ `Dispose` (`() => void`) removes everything a binder added, window and document listeners included;
1972
+ calling it twice is safe.
1973
+
1974
+ ### Binders
1975
+
1976
+ - `bindStrip(root, options?)`: a `strip` root with a `strip-track` (the native horizontal scroller),
1977
+ optional `strip-prev` / `strip-next`, `strip-dots` (one child per item) and `strip-counter`; items
1978
+ are the `strip-item`s, else the track's children. Writes `data-current` on the item, `data-active`
1979
+ on its dot, `disabled` on the arrows, `data-engaged` / `data-dragging` on the root, the dots'
1980
+ `aria-label` and the counter text from `data-template` (`{current}`, `{count}`). → `StripHandle`
1981
+ (`index`, `total`, `goTo(i, "instant" | "smooth")`, `step(±1)`, `reset()`, `dispose`)
1982
+ - `StripOptions`: `paging` `"page"` (every item the track's width; default) or `"snap"`; `edges`
1983
+ `"stop"` (default) or `"wrap"`; `drag` (mouse drag, default for snap); `deferSlides` (default
1984
+ true: items get their `data-src` / `data-srcset` on intent). The root's `data-paging` /
1985
+ `data-edges` set them from markup
1986
+ - `bindLayer(dialog, options?)`: a `<dialog data-part="layer">` with one URL-hash history entry per
1987
+ open; openers are `layer-open` with `aria-controls=<dialog id>` anywhere, closers `layer-close`
1988
+ inside. Every close (X, ESC, backdrop, Back) goes through `history.back()`; a URL hash naming the
1989
+ layer opens it. Body scroll locked while open; focus moves only for keyboard users. Throws without
1990
+ an id or `name`. → `LayerHandle` (`open(opener?)`, `close()`, `isOpen`, `dispose`)
1991
+ - `LayerOptions`: `name` (the hash; default the dialog id), `scroller` (reset on close; default its
1992
+ `layer-body`), `closeOnBackdrop` (true), `onOpen(opener)`, `onClose()`
1993
+ - `bindGallery(root)`: a `rental-gallery`: its strips as page strips, its `<dialog>`s
1994
+ `rental-gallery-grid`, `rental-gallery-viewer` and `rental-floorplan` as layers. Any element whose
1995
+ `aria-controls` names one opens it; a viewer opener carries `data-index`. Arrow keys step the
1996
+ viewer; the mobile strip returns to the photo last viewed; floor plans publish `--fp-ar` and are
1997
+ warmed on hover
1998
+ - `bindReveal(root, options?)`: `RevealOptions` (`step`, `collapsible`). `reveal-item[hidden]` items
1999
+ revealed by `reveal-more` (hides itself when nothing remains), hidden again by `reveal-less`;
2000
+ `data-open` on the root; `data-step` on the root sets the step
2001
+ - `bindClamp(root)`: `clamp-toggle` shows only while `clamp-text` overflows its CSS line clamp;
2002
+ toggles `data-expanded` on the root
2003
+ - `bindShare(button, { origin?, strip? })`: the canonical link (`shareUrl`): the share sheet on
2004
+ touch devices, else the clipboard, else a prompt. After a copy: `data-state="done"` for 2 s,
2005
+ `[data-share-idle]` / `[data-share-done]` swap, the `share-status` live region announces
2006
+ `data-message`. `data-share-origin` and `data-share-title` from markup
2007
+ - `bindImageFade(root)`: `loading="lazy"` images get `data-loaded="fade"` when they land, `"cache"`
2008
+ when already decoded or failed; the root carries `data-image-fade` while bound
2009
+ - `bindFavorites(root, binding)`: `FavoritesBinding` (`storageKey`). Hearts (`[data-fav]` /
2010
+ `favorite`, id from `data-fav-id` or the enclosing `[data-result-card]`) get `aria-pressed` and
2011
+ `data-saved`; a click toggles (unless another handler took it) and sets `data-fav-burst`; badges
2012
+ (`[data-fav-badge]` / `favorite-badge`) show the count, `data-active="true"` above 0 (no attribute at 0;
2013
+ `[data-active]` and `[data-active="true"]` both match), `data-fav-bump` after a change,
2014
+ `aria-label` from `data-template` / `data-template-one`. Dispatches `merkliste:changed`
2015
+ - `withViewTransition(mutate)`: runs `mutate` in a view transition where available and motion is not
2016
+ reduced
2017
+ - `trackInputModality()`, `focusForKeyboard(element)`: the page's pointer-vs-keyboard flag
2018
+ (ref-counted); focus only when the last input was the keyboard
2019
+
2020
+ ### Map engines
2021
+
2022
+ MapLibre is driven through an injected loader, so headless has no maplibre dependency
2023
+ (`typeof import("maplibre-gl")` satisfies `MapLibreLike`).
2024
+
2025
+ - `createMapSurface(options)`: one MapLibre map on one container, built when needed → `MapSurface`
2026
+ (`build()`, `map()`, `zoomIn()`, `zoomOut()`, `fit(bounds, padding, maxZoom)`, `resize()`,
2027
+ `destroy()`). It puts nothing on the map
2028
+ - `MapSurfaceOptions`: `container`, `load` (`MapLibreLoader`, called at most once per page),
2029
+ `styleUrl`, `camera` (`MapCamera`: `{ center, zoom }` or
2030
+ `{ bounds: () => MapBounds, padding, maxZoom }`), `scrollZoom`, `start` (`"near"`: once the
2031
+ container is near the viewport with a width; `"demand"`: on `build()` only, for click-to-load and
2032
+ layers), `nearMargin` (`"100% 0px"`), `onReady(map, lib)`, `onView(bounds | null, byGuest)`
2033
+ - `bindResultMarkers(map, lib, options)`: the results map's markers: a clustered GeoJSON source and
2034
+ an HTML-marker diff on every render → `ResultMarkers` (`update(points, layout, priced)`,
2035
+ `expand(groupKey | null, pinWidthPx)`, `dispose()`). `ResultMarkersOptions`: `points`, `layout`,
2036
+ `cluster` (`false` or `ClusterOptions`), `priced`, `onMarkers(markers)` (render into each
2037
+ `ResultMarker`'s `body`), `onPin(id)`, `onGroup(key)`, `onBackground()`
2038
+ - `bindPlaceMarkers(map, lib, options)`: fixed markers (`PlaceMarkersOptions`: `markers` from
2039
+ `mergePlaces`, `anchor` `"bottom"` or `"center"`, `render(body, marker)`, `popup` text or null) →
2040
+ `Dispose`
2041
+ - `observeResultCardHover(onHover)`: which `[data-result-card]` the pointer or focus is on,
2042
+ delegated on the document → `Dispose` (feed it to `store.setHighlighted`)
2043
+ - `markerElement({ tag, part })`, `CLICK_TOLERANCE_PX` (12): a marker's shell (MapLibre positions
2044
+ it; never style it) and body (`data-part`, every visual); the one press-vs-drag threshold on the
2045
+ map and on a pin
2046
+ - `MapLibreLike`, `MapLike`, `MarkerLike`, `PopupLike`, `GeoJsonSourceLike`, `MapLibreLoader`: the
2047
+ part of maplibre-gl the engines call, structurally
2048
+
2049
+ ```ts
2050
+ import {
2051
+ createResultsMapController,
2052
+ DEFAULT_CLUSTER,
2053
+ DEFAULT_FIT,
2054
+ DEFAULT_MAP_STYLE_URL,
2055
+ type MapBounds,
2056
+ type SearchStore,
2057
+ } from "@v-office/website-headless";
2058
+ import {
2059
+ bindResultMarkers,
2060
+ createMapSurface,
2061
+ observeResultCardHover,
2062
+ } from "@v-office/website-headless/enhance";
2063
+
2064
+ declare const store: SearchStore; // the page's store (acquireSearchStore)
2065
+ const controller = createResultsMapController(store, { regionScope: "release-on-move" });
2066
+ const surface = createMapSurface({
2067
+ container: document.getElementById("results-map") as HTMLElement,
2068
+ load: () => import("maplibre-gl"),
2069
+ styleUrl: DEFAULT_MAP_STYLE_URL,
2070
+ camera: {
2071
+ bounds: () => controller.getState().frame as MapBounds,
2072
+ padding: DEFAULT_FIT.padding,
2073
+ maxZoom: DEFAULT_FIT.maxZoom,
2074
+ },
2075
+ scrollZoom: false,
2076
+ start: "demand", // build on the visitor's click: the tiles are a third-party request
2077
+ onReady: (map, lib) => {
2078
+ const state = controller.getState();
2079
+ const markers = bindResultMarkers(map, lib, {
2080
+ points: state.points,
2081
+ layout: state.layout,
2082
+ cluster: DEFAULT_CLUSTER,
2083
+ priced: state.priced,
2084
+ onMarkers: (shown) => {
2085
+ const labels = new Map(
2086
+ controller.getState().points.map((point) => [point.id, point.label ?? ""]),
2087
+ );
2088
+ for (const marker of shown)
2089
+ marker.body.textContent =
2090
+ marker.kind === "pin" ? (labels.get(marker.id) ?? "") : String(marker.count);
2091
+ },
2092
+ onPin: (id) => controller.openPin(id),
2093
+ onGroup: (key) => controller.toggleGroup(key),
2094
+ onBackground: () => controller.toggleGroup(null),
2095
+ });
2096
+ controller.subscribe((next) => markers.update(next.points, next.layout, next.priced));
2097
+ },
2098
+ onView: (bounds, byGuest) => controller.report("inline", { bounds, byGuest }),
2099
+ });
2100
+ document.querySelector("[data-map-open]")?.addEventListener("click", () => {
2101
+ if (controller.getState().status === "ready") void surface.build(); // "ready" has pins, so a frame
2102
+ });
2103
+ const stopHover = observeResultCardHover((id) => store.setHighlighted(id));
2104
+ // teardown: stopHover(); surface.destroy(); controller.dispose();
2105
+ ```
2106
+
2107
+ ## Part vocabularies
2108
+
2109
+ Two `data-part` vocabularies live in headless because something other than styling depends on them:
2110
+ the legal structure of the checkout and the contract between the engines and any markup. The
2111
+ components' own `<SURFACE>_PARTS` are style hooks and live there. Renaming or removing a part or a
2112
+ marker is a major.
2113
+
2114
+ ### `CHECKOUT_PARTS`
2115
+
2116
+ | key | `data-part` | on |
2117
+ | ------------------- | ----------------------------- | ------------------------------------------------------------------------ |
2118
+ | `priceTotal` | `checkout-price-total` | the price the guest is about to owe (the summary, and above the button) |
2119
+ | `insurancePremium` | `checkout-insurance-premium` | the insurance premium: a second figure beside each total, never summed |
2120
+ | `submit` | `checkout-submit` | the order button; carries `data-booking-kind` (`BOOKING_KIND_ATTRIBUTE`) |
2121
+ | `terms` | `checkout-terms` | the terms notice, or the terms checkbox's label |
2122
+ | `consent` | `checkout-consent` | every consent checkbox |
2123
+ | `insuranceDocument` | `checkout-insurance-document` | one link per pre-contractual insurance document |
2124
+ | `insuranceConfirm` | `checkout-insurance-confirm` | the guest's receipt confirmation for those documents |
2125
+
2126
+ `CheckoutPart` is one value; `BOOKING_KIND_ATTRIBUTE` is `"data-booking-kind"` (absent reads as a
2127
+ booking, so only a button that says so claims the option wording).
2128
+
2129
+ ### `ENHANCE_PARTS`
2130
+
2131
+ | engine | `data-part` values |
2132
+ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2133
+ | strip | `strip`, `strip-track`, `strip-item`, `strip-prev`, `strip-next`, `strip-dots`, `strip-counter` |
2134
+ | layer | `layer` (a `<dialog>`), `layer-open` (with `aria-controls`), `layer-close`, `layer-body` |
2135
+ | reveal, clamp | `reveal`, `reveal-item`, `reveal-more`, `reveal-less`; `clamp`, `clamp-text`, `clamp-toggle` |
2136
+ | share | `share`, `share-status` |
2137
+ | favourites | `favorite`, `favorite-badge` |
2138
+ | gallery | `rental-gallery`, `rental-gallery-tile`, `rental-gallery-all`, `rental-gallery-grid`, `rental-gallery-viewer`, `rental-gallery-caption`, `rental-gallery-counter`, `rental-floorplan-open`, `rental-floorplan` |
2139
+
2140
+ `EnhanceParts` is the object's type, `EnhancePart` one value. The map engines write their own parts
2141
+ on marker bodies: `map-pin` (with `data-id`), `map-cluster` and `map-group` (with `data-leaf-ids`,
2142
+ `|`-joined; a group also `data-expanded`), `map-marker` and `map-popup` on place maps.
2143
+
2144
+ ### Markers the engines and gates read
2145
+
2146
+ - `GATE_MARKERS` (`GateMarkers`): attributes the platform's page gates read (fact check, room
2147
+ measurements), rendered on the same elements as before: `data-rental-stats`,
2148
+ `data-rental-description` … `data-rental-description-end`, `data-rental-amenities`,
2149
+ `data-amenities-modal` … `data-amenities-overlay-end`, `data-room-attr`, `data-review-card`.
2150
+ - Legacy kit markup still binds: `.card-images` with `[data-img-track]`, `[data-img-prev]`,
2151
+ `[data-img-next]`, `[data-dots]` (strips); `[data-share]`, `[data-share-idle]`,
2152
+ `[data-share-done]`, `[data-share-live]` (share); `[data-fav]`, `[data-fav-id]`,
2153
+ `[data-fav-badge]` (favourites).
2154
+ - `[data-result-card="<id>"]` names a result card: the favourites binder and the card-hover observer
2155
+ read it.
2156
+
2157
+ ## Migrating from 0.1.x
2158
+
2159
+ 1.0.0 is a redesign with no 0.1.x compatibility. In short:
55
2160
 
56
- ## The one hard rule
2161
+ - The peer floor is `@v-office/website-sdk >=2.25.0`.
2162
+ - Surfaces take an `SdkSetup` (`{ config, options }`) instead of `SdkConfig`, and
2163
+ `createSdk(setup, mode?)` builds from it; `shareSdk` and `loadSharedSdk` share one instance per
2164
+ page; `sdkConfigFromEnv` reads the env and has no backend default.
2165
+ - Every SDK runner takes the SDK first: `fetchQuote(sdk, request)`,
2166
+ `runLiveSearch(sdk, search, options)`, `submitBooking(sdk, quote, contact, options)`.
2167
+ - About 70 names of the 0.1.x root modules are gone (`sort`, `filters`, `regions`, `dates`,
2168
+ `format`, `booking-snapshot`; `tidyPrice`, `nightlyPrice`, `SORT_OPTIONS`, `CHECKOUT_RETURN_PATH`,
2169
+ …); `SearchResultItem` and `Stay` are reshaped (`Stay` in ISO days with a `party`).
2170
+ - `createCheckoutFlow` has `phase`, `blockers`, `onEvent` and `pay(option)`; `returnPath` and
2171
+ `messages` are required.
2172
+ - `./enhance`: `enhance()` and the `bind*` engines replace `initImageCarousels`, `initGallery` and
2173
+ `initHistoryOverlay`; legacy `.card-images` / `data-img-*` markup still binds.
2174
+ - New: the search form, the search store, URL codec, guests, offers, the results map controller, the
2175
+ rental and card models, showcase selectors, the quote flow, the contact form and the favourites
2176
+ store.
2177
+ - Unchanged on purpose: every storage key (`<namespace>-merkliste`, `voffice:checkout-context`,
2178
+ `voffice:booking-return`), every URL key and the German sort values, so existing links and every
2179
+ guest's Merkliste survive.
57
2180
 
58
- **This package never constructs an SDK instance, never renders markup and never depends on a UI framework.**
59
- Construction needs site credentials (`src/lib/voffice.ts` in each site);
60
- rendering is the components' or the site's job. Every function takes the SDK
61
- instance (or a `getSdk` thunk) from the caller. Copy/messages are always
62
- parameters — the rules live here, the words live in the site.
2181
+ The release's `MIGRATION.md` and `CHANGELOG.md` (in `instructions/versions/<version>/`) carry the
2182
+ full list and what to do.
63
2183
 
64
2184
  ## Development
65
2185
 
66
2186
  ```bash
67
- pnpm install
2187
+ # at the repository root
2188
+ pnpm install # website-sdk is a workspace link
2189
+ pnpm --filter @v-office/sdk-core --filter @v-office/website-sdk run build # tsc and the tests need the built SDK
2190
+ # in packages/website-headless
68
2191
  pnpm build # tsdown → dist/ (index, enhance; .mjs + .d.mts)
69
2192
  pnpm check # tsc · oxlint · oxfmt --check
70
- pnpm test # vitest, jsdom environment
2193
+ pnpm fmt # oxfmt (writes)
2194
+ pnpm test # vitest (jsdom), then test/dist.node.mjs on the build, imported by its name (run pnpm build first)
71
2195
  ```
72
2196
 
73
- The tests cover the pure rules (validation, insurance gate, stay helpers,
74
- quote adapter), the checkout flow driven as a store
75
- against a fake SDK, and the structural gate against rendered markup. Nothing
76
- books or pays for real. vitest is used because the structural gate needs a
77
- DOM (jsdom); the SDKs use `node --test` because they do not.
2197
+ The tests run against fake SDKs: nothing books, pays or sends for real, and no test reaches the
2198
+ network. `src/__tests__/no-site-names.test.ts` keeps customer names, domains and towns out of
2199
+ `src/`. Source files are interface-first (a `PUBLIC SURFACE` banner with every exported type and
2200
+ function documented, then the implementation); the contributor rules are in `AGENTS.md`.
78
2201
 
79
- ## Publishing
2202
+ ### Releasing
80
2203
 
81
- The **Publish** workflow (`.github/workflows/publishSDK.yml`, choice
82
- `website-headless`) publishes the exact version in `package.json` to npm after
83
- build, check and tests pass; it never bumps versions. Set the version by hand,
84
- merge, run the workflow. Consumers pin the exact version and receive updates
85
- through fleet rollouts (docs/website-platform/process/06-package-rollout.md).
2204
+ See [docs/internal/releasing/overview.md](../../docs/internal/releasing/overview.md) in the
2205
+ repository. `node .github/release/cli.mts prepare website-headless <bump>` sets the version and
2206
+ scaffolds `instructions/versions/<version>/` with the notes and `release.json`; the
2207
+ `Release packages` workflow publishes what is on `main` after build, check and tests pass. Consumers
2208
+ pin the exact version and receive updates through fleet rollouts.