@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 +2177 -54
- package/dist/enhance/index.d.mts +501 -180
- package/dist/enhance/index.mjs +1388 -472
- package/dist/index-CPMXjfFH.d.mts +2724 -0
- package/dist/index.d.mts +2602 -1165
- package/dist/index.mjs +8568 -1735
- package/dist/parts-CS1VBrPt.mjs +648 -0
- package/dist/sdk-claim-CsYFzvEc.mjs +72 -0
- package/dist/sdk-create-Bsd9lqj_.mjs +9 -0
- package/instructions/CHANGELOG.md +9 -0
- package/instructions/MIGRATION.md +9 -0
- package/instructions/versions/0.1.1/CHANGELOG.md +7 -0
- package/instructions/versions/0.1.1/MIGRATION.md +4 -0
- package/instructions/versions/0.1.1/release.json +3 -0
- package/instructions/versions/0.2.0/CHANGELOG.md +17 -0
- package/instructions/versions/0.2.0/MIGRATION.md +16 -0
- package/instructions/versions/0.2.0/release.json +3 -0
- package/instructions/versions/1.0.0/CHANGELOG.md +170 -0
- package/instructions/versions/1.0.0/MIGRATION.md +252 -0
- package/instructions/versions/1.0.0/release.json +10 -0
- package/package.json +14 -11
package/README.md
CHANGED
|
@@ -1,85 +1,2208 @@
|
|
|
1
1
|
# @v-office/website-headless
|
|
2
2
|
|
|
3
|
-
The **
|
|
4
|
-
|
|
5
|
-
`@v-office/website-components`
|
|
6
|
-
|
|
7
|
-
|
|
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:
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
171
|
+
createSdk,
|
|
172
|
+
fetchLegalDocuments,
|
|
173
|
+
loadRentalCatalogue,
|
|
174
|
+
resolveFilters,
|
|
175
|
+
shareSdk,
|
|
37
176
|
} from "@v-office/website-headless";
|
|
38
177
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
2202
|
+
### Releasing
|
|
80
2203
|
|
|
81
|
-
|
|
82
|
-
`website-headless
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
through fleet rollouts
|
|
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.
|