@v-office/website-headless
v1.1.0
Published
Framework-free behaviour layer for v-office websites, never UI: stores, pure rules and SDK runners for the search bar, results, map, showcase, quote, checkout and contact form, the rental and card models, and progressive-enhancement engines for server-ren
Readme
@v-office/website-headless
The behaviour layer of v-office websites: stores, pure rules, SDK runners, models, constants and
the part vocabularies that something other than styling depends on. It never renders. A site uses it
through @v-office/website-components (React surfaces over these stores) or builds its own UI over
the same stores and is held to the same rules: the quote, checkout and contact flows, the insurance
document gate and the checkout's structural requirements live here, not in a component.
@v-office/sdk-core domain contracts + PMS adapters
@v-office/website-sdk website facade (static + live namespaces)
@v-office/website-headless ← THIS: stores, rules, SDK runners, models, enhancement engines
@v-office/website-components imported UI over these stores (a site may build its own instead)
userland pages, brand components, tokens, copyWhat it is not. No React, no markup, no copy (stores take messages and return kinds and
numbers), no window or document in the . entry, no SDK built on its own initiative, and no
site-specific default: a difference between sites is a documented option whose default is right for
a site that never thought about it. Facts about the business (routes, the address decision, the
favourites namespace, the backend) have no default at all.
Contents
- Install and entry points
- Wiring a site
- Stores
- Conventions and shared types
- Search form
- URL parameters
- Guests
- Search store and live search
- Offers
- Results map
- Rental catalogue and detail model
- Cards and facts
- Showcase selectors
- Quote flow and availability
- Checkout flow
- Contact form
- Countries
- Legal documents
- Favourites
- Shared helpers
- Enhance engines
- Part vocabularies
- Migrating from 0.1.x
- Development
Install and entry points
pnpm add --save-exact @v-office/website-headless @v-office/website-sdk- The only peer is
@v-office/website-sdk >=2.25.0 <3. There is no runtime dependency. Sites pin exact versions; updates arrive through fleet rollouts. maplibre-glis not a dependency: the map engines take a loader (() => import("maplibre-gl")) from the caller.
| import | runs | contents |
| ------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| @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 |
| @v-office/website-headless/enhance | browser only | enhance(root, options) and the binders it composes over server-rendered markup, plus the MapLibre engines |
ENHANCE_PARTS and GATE_MARKERS are exported from both entries, so a build can render the markup
contract without importing browser code.
The documented exceptions to "no DOM in ." take what they need as an argument or only touch the
browser when called: assertCheckoutStructure(root) takes a DOM root; browserStorage(), the
favourites store and the payment-return snapshot use Web Storage and fail soft without it;
loadRentalIndex and loadRentalFacts call fetch; resultsQueryFlagScript() returns script
source for the page's <head>.
Wiring a site
The SDK setup
Every SDK-using surface takes one plain-data SdkSetup ({ config, options? }). A site writes it
once in src/data/sdk.ts:
import { sdkConfigFromEnv, type SdkSetup } from "@v-office/website-headless";
export const SDK: SdkSetup = {
// Each name spelled out: Vite inlines only the `import.meta.env.NAME` it sees, so
// `import.meta.env` passed whole can arrive without them in a build.
config: sdkConfigFromEnv({
CMS_BACKEND: import.meta.env.CMS_BACKEND,
CMS_STAGE: import.meta.env.CMS_STAGE,
HUB_API_KEY: import.meta.env.HUB_API_KEY,
HUB_GRAPHQL_URL: import.meta.env.HUB_GRAPHQL_URL,
HUB_V0_API_BASE_URL: import.meta.env.HUB_V0_API_BASE_URL,
HUB_V1_API_BASE_URL: import.meta.env.HUB_V1_API_BASE_URL,
IMAGE_PROXY_BASE_URL: import.meta.env.IMAGE_PROXY_BASE_URL,
VOFFICE_LOCAL_DEV_ACCESS_TOKEN: import.meta.env.VOFFICE_LOCAL_DEV_ACCESS_TOKEN,
VOFFICE_API_ENDPOINT: import.meta.env.VOFFICE_API_ENDPOINT,
VOFFICE_SEARCH_ENDPOINT: import.meta.env.VOFFICE_SEARCH_ENDPOINT,
VOFFICE_IMAGE_BASE_URL: import.meta.env.VOFFICE_IMAGE_BASE_URL,
}),
// Only the options the site uses: the setup is serialized into every island.
options: { translationOverrides: {} },
};sdkConfigFromEnv(env): The fleet's env names →SdkConfig.envis anEnvRecord; it never readsprocess.env; pass a literal that names each variable (above), orprocess.envin a Node script. Only non-empty strings count (Vite's boolean flags are ignored). Throws oneSdkEnvErrornaming every missing or conflicting variable at once.SDK_ENV: The env names (a platform contract: the AI Studio builder readsHUB_API_KEY; renaming one is a major).SdkEnvError:missing: string[],conflicting: string[]; the message names variables, never a value.sdkConfigFor({ backend, stage, token }):SdkConfigForInput→ a full config with the platform's public endpoints. Spread extras on top:{ ...sdkConfigFor(…), facilityObjectGroupRelationAttributeId: 123 }.VOFFICE_ENDPOINTS,HUB_ENDPOINTS:VofficeEndpointTable/HubEndpointTable: the public https endpoints (VofficeEndpoints,HubEndpoints) perStage("development","production";STAGESlists both).SdkConfig: The SDK's ownWebsiteSDKConfig, withbackendexplicit on v10. A site with an unusual setup (a dev proxy) writes the literal.SdkOptions: The SDK options as data (translationOverrides,customAttributes,rentalHighlightPrioritization, …), withoutsearchAllRentalsAtOnce(that isSdkMode).SdkSetup:{ config: SdkConfig; options?: SdkOptions }.Backend,Stage,Locale:"v9" | "v10";"development" | "production"; the SDK's"de-DE" | "en-US"(there is no third locale).
sdkConfigFromEnv rules, in order:
CMS_BACKENDmust bev9orv10. There is no silent default.- The backend's token is required:
HUB_API_KEY(v9, the public be-on key) orVOFFICE_LOCAL_DEV_ACCESS_TOKEN(v10). - With
CMS_STAGE(development|production) set, the platform endpoints of that stage are used and the URL variables must be absent (two sources for one value is an error, not a merge). - Otherwise every URL variable of the backend is required: v9
HUB_GRAPHQL_URL,HUB_V0_API_BASE_URL,HUB_V1_API_BASE_URL,IMAGE_PROXY_BASE_URL; v10VOFFICE_API_ENDPOINT,VOFFICE_SEARCH_ENDPOINT,VOFFICE_IMAGE_BASE_URL.
Which SDK instance
The package constructs an SDK only when asked. Every runner and store takes the SDK as its first
argument, as an SdkSource: an instance, or a getter (() => WebsiteSdk | Promise<WebsiteSdk>).
Every store calls a getter once, on its first request, and again only after a failed resolve, so a
getter that constructs (a lazy shareSdk claim) constructs once per store.
createSdk(setup, mode?): Build time and scripts. The caller owns the instance: every build-time loader takes the same one (the API allows 1 request per second per instance), and the caller disposes it (await sdk.dispose()) after the last loader, in afinally, so a failed loader releases it too.shareSdk(setup, mode?): Browser surfaces. Returns aSharedSdk({ sdk, release() }): every claim with an equal setup and mode (compared as key-sorted JSON) gets the page's same instance, so one 1 req/s queue and one cache serve every surface. The lastrelease()disposes it, unless a new claim arrives within the same task (a React StrictMode remount keeps it).loadSharedSdk(setup, mode?):shareSdkwith the SDK imported on first call (a dynamicimport()): a page whose only SDK use is a submit (a contact form) ships no SDK code up front. Resolves to the instanceshareSdkholds for the same setup.resolveSdk(source): The instance behind anSdkSource(a promise). Call it once per call site and keep the result.SdkMode:"default"(paged search andmapSearch) or"all-rentals-at-once"(searchAllRentalsAtOnce: at most 100 rentals priced in one call, no cursor, nomapSearch). A store or walker over such an instance takes the same value assdkMode.WebsiteSdk,SdkSource: A constructed SDK (either backend); what every store takes first.describeSdkError(error): A failed SDK call and its wholecausechain as indented JSON (stacks skipped).console.error(error)does not print the cause, which is where the backend's reason is.logSdkFailure(scope, error):console.error("[sdk] <scope> failed:", description), tagged by SDK operation ("search.search","contact.submit"). Every runner in this package already logs through it.
import {
createSdk,
fetchLegalDocuments,
loadRentalCatalogue,
resolveFilters,
shareSdk,
} from "@v-office/website-headless";
// Build time: one instance for every loader, disposed after the last one.
const sdk = createSdk(SDK);
try {
const rentals = await loadRentalCatalogue(sdk, { locale: "de-DE" });
const filters = resolveFilters(
await sdk.static.filter.getFilters({ locale: "de-DE" }),
FILTER_SPEC,
);
const legal = await fetchLegalDocuments(sdk, "de-DE");
// … the plain results go to the pages; no page builds an SDK of its own
} finally {
await sdk.dispose();
}
// Browser: claim the page's instance, release it when the surface goes.
const shared = shareSdk(SDK);
// … createSearchStore(shared.sdk, …), createQuoteFlow(shared.sdk, …)
shared.release();Two surfaces whose setups differ in any option get two instances, each with its own rate limit. Pass
the one SDK constant everywhere.
Site constants
A site writes each decision once and passes the constant to every surface that needs it, so two surfaces cannot disagree.
| constant | type | passed to |
| ----------------- | ----------------------------------------------------------- | -------------------------------------------------------------- |
| SDK | SdkSetup | every SDK-using surface |
| SEARCH_CONTRACT | SearchContract (backend, nightRangeSearch, aliases) | search form, search store, offer chips |
| GUESTS | GuestOptions (limits, child age band, default adults) | search form, quote flow, checkout flow |
| DAY_PRICES | DayPrices | the booking calendars (dayPriceLabel) |
| POLICY_CHOICE | PolicyChoiceMode | quote flow and checkout flow (policyChoice) |
| BOOKABLE | BookableFrom | the search form's dates.bookable and the quote's notice |
| ADDRESS | RentalAddressMode | rentalAddressLine (the rental heading and map) |
| FAVORITES_KEY | string from favoritesStorageKey(namespace) | enhance(), createFavorites/acquireFavorites, every heart |
@v-office/website-components adds CARD (RentalCardOptions, which extends CardModelOptions)
and VOICE (Voice).
Build time and browser
Everything in . runs in both places, but some calls belong to one of them:
- Build time (they spend rate-limited requests or need full ICU):
loadRentalCatalogue,fetchRentalFlags,resolveSearchGroups,measureFilters,findOfferWindows,findCampaignStart,fetchLegalDocuments,countryOptions,rentalIndexEntries,cardSource,rentalFactsMap,buildShowcaseGroups,resolveFilters. Their results are plain JSON and are passed to islands as props. - Browser: the stores (
createSearchForm,createSearchStore,createQuoteFlow,createCheckoutFlow,createContactForm,createFavorites),loadRentalIndex,loadRentalFacts,readPaymentReturn, and all of./enhance.
Stores
Every flow is a store: a UI reads getState(), subscribes, and calls actions. The state object is a
new reference on every change and the same reference otherwise, so it works as a React external
store, a Vue shallowRef, a Svelte store or a plain listener.
| export | what it is |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Store<S> | getState(): S and subscribe(listener): Unsubscribe. A listener is not called for the state current at subscription. |
| WritableStore<S> | Adds setState(next \| (prev) => next); the same reference is a no-op. S is never a function itself. |
| createStore(initial) | The primitive every flow builds on (a custom UI may use it for its own state). |
| Listener<S>, Unsubscribe | The listener and its removal (calling it twice is harmless). |
Conventions every store follows:
create<Thing>(sdk?, options)returns the store plus its actions and, where it holds timers or subscriptions, adispose()that stops them and drops late answers. A store never disposes the SDK.- Options are plain data (an island receives them as props). Stateful factories take
now?: () => Datefor tests and read it when needed; pure rules taketoday?: IsoDay. acquire<Thing>(key, create)gives every island on the page the same instance through one ref-counted registry onglobalThis[Symbol.for("@v-office/website-headless/<namespace>")], so two bundles of this package (an island chunk and a layout script) still meet.
@v-office/website-components ships the React bindings (useSearchForm, useSearchStore,
useQuoteFlow, useCheckoutFlow, useGuestForm, useContactForm, useFavorites, useSdk,
useLazySdk, useEnhance). Any other framework needs only this:
import { useSyncExternalStore } from "react";
import type { Store } from "@v-office/website-headless";
export function useStore<S>(store: Store<S>): S {
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
}A plain <script>:
import { createSearchForm } from "@v-office/website-headless";
const form = createSearchForm({ ...SEARCH_CONTRACT, guests: GUESTS });
const stop = form.subscribe((state) => {
document.body.toggleAttribute("data-search-ready", state.resolved);
});
form.load(location.search);
// later: stop(); form.dispose();Conventions and shared types
The rules
- Copy never enters headless. Stores take
messagesobjects (CheckoutMessages,ContactFormMessages,ValidationMessages) and return kinds and numbers; the components' copy tables satisfy those message types structurally. PMS strings (prices, reasons, labels) pass through verbatim. - Dates are strings.
IsoDay(YYYY-MM-DD, a local calendar day) andIsoMonth(YYYY-MM) at every public boundary;Dateonly for injected clocks. - Real data only. Absent data is
undefined,nullor[], never0or"": no fallback town, no guessed count, no invented price. Money is the SDK's formatted string; no amount is recomputed, and an offer's amount is carried nowhere. - The SDK comes in first (
fetchQuote(sdk, request)), and is constructed only bycreateSdk,shareSdkandloadSharedSdk. book(),pay()and the contactsubmit()are user-initiated only. They create a real reservation, start a real payment and post a real request into the PMS. No test, job or smoke step calls them against a real SDK.- No site names. The source names no customer, domain or town; a ruling is an option.
One name per concept
| name | type (where) | meaning |
| -------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| contract | SearchContract (headless url) | how the search runs on this backend; one value for the search form, the search store and the offer chips |
| 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 |
| whenEmpty | WhenEmpty (headless search) | what a filter or option that matches nothing does: "drop", "disable", "show" |
| 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" |
| voice | Voice (components) | how German copy addresses the reader: "neutral" (default), "du", "sie"; English ignores it. Headless carries no copy |
| headingLevel | HeadingLevel \| "none" (components) | the level of a component's own heading; a custom UI decides its own |
| 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 |
Events, storage keys and URL keys
These are contracts: renaming one is a major. Headless hands events to onEvent callbacks; the
components dispatch them on window, and a custom UI dispatches the same shapes. No detail carries
personal data.
| event (constant) | from | detail |
| --------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 } |
| voffice:quote (QUOTE_EVENT) | createQuoteFlow onEvent | QuoteEventDetail: { type: "rate-fault", rentalId, stayKey, code } once per stay |
| voffice:contact (CONTACT_EVENT) | after submit() resolved { ok: true } | ContactEventDetail: { type: "sent", subject } |
| merkliste:changed (FAVORITES_EVENT) | bindFavorites (dispatched by headless) | FavoritesChange: { id, saved }, once per changed rental, also for another tab's change |
| storage | key |
| ------------------------ | ------------------------------------------------------------------------------------------------------ |
| localStorage, favourites | `${namespace}-merkliste` (favoritesStorageKey) |
| sessionStorage, checkout | voffice:checkout-context (CHECKOUT_CONTEXT_KEY), voffice:booking-return (BOOKING_SNAPSHOT_KEY) |
The URL vocabulary is SEARCH_PARAMS; the sort values are
SORT_URL_VALUE.
Shared types
| type | defined in | meaning |
| --------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------- |
| Locale, Backend, Stage | sdk | "de-DE" \| "en-US"; "v9" \| "v10"; "development" \| "production" |
| SdkConfig, SdkOptions, SdkSetup, SdkMode, SdkSource, WebsiteSdk | sdk | see Wiring a site |
| IsoDay, IsoMonth | shared | calendar strings |
| Party, GuestLimits, ChildAgeRange, GuestCaps, GuestOptions | guests | one party model for the search form, the quote and the checkout |
| PriceCents | shared | how a formatted price string is trimmed (cards, pins, calendar cells) |
| Glyph | shared | the closed glyph vocabulary |
| RichBlock, RichInline | shared | sanitized PMS text as data |
| ContactLink, BookableFrom | shared | { label, href }; "not bookable online before from" |
| StorageLike | shared | getItem, setItem, removeItem |
| WhenEmpty | search | "drop" \| "disable" \| "show" |
| RentalCounts, RentalDistance, DistanceKind | rental | counts and distances (absent = undefined) |
| CardSource, RentalFacts, RentalFactsMap, StayPrice | cards | the one card input; build-time facts per rental; a priced stay |
| Stay | checkout | { start, end, party }, a complete stay |
Search form
Module search-bar. The search bar's state and rules: a date picker and a guest picker composed,
the region choice, and the pairs a native GET form submits. It never calls the SDK: the bar
navigates (the form works before hydration), and the results page refines in place.
createSearchForm(options)
options is a SearchFormOptions; returns a SearchForm. Throws (at build) when fields lists
"pets" while dogs are not offered (guests.limits.pets is 0).
| option | default | meaning |
| ---------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| backend, nightRangeSearch, aliases | (the SearchContract) | backend is required |
| 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 |
| regions | [] | the selectable region values (labels are UI); "region" renders only with at least one |
| regionSelect | "multi" | "multi" (OR) or "single" (one region or all) |
| dates | {} | the date picker's options (createDateSearch, without backend and nightRangeSearch) |
| guests | {} | GuestSearchOptions (GuestOptions plus showDefault) |
| childAges | "required" | "required": a child without an age stops the submit; "optional" submits the ages that exist |
| 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 |
| now | () => new Date() | the clock |
SearchFormState: fields (the ones that render), dates (DateSearchState), guests
(GuestSearchState), regions, carried (pairs the bar does not own: sort, filters, property,
campaign, a site's own keys), resolved (false until load() ran: show skeletons, disable the
submit), issue (SearchFormIssue: { kind: "agesMissing" } after a refused submit, cleared by
the next change).
| action | what it does |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| dates, guests | the composed DateSearch and GuestSearch; their actions drive the panels |
| load(search) | read the URL (and the preset) into the pickers |
| toggleRegion(value), selectRegion(value \| null), clearRegions() | the region choice; values outside regions are ignored |
| pairs() | writeSearch(pickers) + carried: the GET form's hidden inputs |
| 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 |
| reset() | pickers and regions back to nothing chosen; carried pairs stay |
| update(options), dispose() | change options (not the backend); stop listening |
import { createSearchForm } from "@v-office/website-headless";
const form = createSearchForm({ ...SEARCH_CONTRACT, regions: ["Nord", "Süd"], guests: GUESTS });
form.load(location.search);
const element = document.querySelector("form[data-search]");
element?.addEventListener("submit", (event) => {
event.preventDefault();
const result = form.submit(location.search);
if (!result.ok) return; // state.issue: open the guest panel and ask for the ages
const query = new URLSearchParams(result.pairs.map(([key, value]) => [key, value]));
location.assign(`/suchen?${query.toString()}`);
});createDateSearch(options)
The period a guest is picking (DateSearchOptions → DateSearch). The calendar always shows the
guest's own stay; only what a submit sends widens (± steps). Which controls exist comes from the
SDK's capability helpers for the backend.
| option | default | meaning |
| ------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| backend | required | decides the flexible tab and how a ± step is spelled |
| nightRangeSearch | true | whether the backend prices a minNights/maxNights window |
| flexSteps | [0, 1, 2, 3, 7, 14] | ± chips in days; [] or [0] = no chip row. On v10 always, on v9 only with nightRangeSearch |
| flexibleStays | ["weekend", "week", "month"] | [] removes the flexible tab (v10 only) |
| nightRangeRow | false | render the Min./Max. nights row (beside exact dates and a window of at least 2 nights) |
| minStayNights | 1 | closer departure days are disabled |
| bookable | none | BookableFrom: from floors the calendar, the month strip and ± windows; deferred is true while today is before noticeUntil ?? from |
| now | () => new Date() | read on every action, so a bar left open past midnight moves its floor |
DateSearchState: mode (DateMode: "dates" | "flexible"), from, to, flexDays,
nightRange, stay (FlexibleStay | null), months, windowNights, controls (DateControls:
flexibleTab, flexSteps, nightRangeRow: what to render now), floor, deferred,
monthOptions (twelve months from the floor's month) and period (SearchPeriod | null, what a
submit sends).
Actions: pick(day) (first click sets from, second to, a click on a finished stay starts a new
one), isDisabled(day), setMode, setFlexDays, setMinNights, setMaxNights (moving one bound
past the other pushes it along), setStay, toggleMonth, load(period) (a period the controls
cannot show loads as empty), reset(), update(options).
Period labels
describePeriod(period, locale, today?)→PeriodLabel(dates,flexDays,nights,stay,months): the summary parts of aSearchPeriod, a partial pick ({ kind: "partial", from, to }) ornull. No copy words: the UI adds "± {days} Tage" and the like.formatStayRange(from, to, locale, today?)→"4. – 11. Sept.","30. Dez. 2026 – 3. Jan. 2027": the month once, the year only when it is not the current one, months throughIntl(the result cards use the same).
Rental jump
A rental-name search box over the site's static index (the live search has no free-text key, so this is catalogue matching, never a filter).
rentalIndexEntries(rentals, href, { code? })(build) →RentalIndexEntry[](id,name,href,code?,place?= the town).hrefis the site's own detail URL.loadRentalIndex(url)(browser) fetches the JSON once per URL per page; a failure is not cached. Rows without a stringid,nameandhrefare dropped.matchRentals(entries, query, options?)(RentalMatchOptions:limit8,minChars2) →RentalMatch(hits,total). Every query word must occur in the name, place or code, in both umlaut spellings ("duene" and "dune" find "Düne"); ranked code exact, name exact, name prefix, word start, contains.foldSearchText(raw): lowercase, ä→ae ö→oe ü→ue ß→ss, other diacritics stripped.
URL parameters
Module url. The search funnel's URL vocabulary and its codec: written canonically, read leniently
(the SDK's aliases, the legacy pets flag and a site's own aliases are read, never written).
SEARCH_PARAMS
Every key of the funnel's wire format (SearchParamKeys). Links live in bookmarks, indexes and
mails: renaming one is a major.
| key | meaning |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| start, end | arrival and departure (YYYY-MM-DD); on a ± or night-range search the window's bounds |
| dates, nights | v10 ± window DD-MM-YYYY,DD-MM-YYYY and its stay length |
| month, weekend | v10 flexible months (MM-YYYY, repeatable) and the weekend flag |
| minNights, maxNights | the night range inside a start/end window |
| flex | the ± step the guest chose; UI only, never sent to the SDK |
| adults, children, childrenAges, babies | the party; childrenAges is one comma list aligned to children |
| petsCount, pets | dogs: search pages write petsCount, rental and checkout pages pets (an integer); old search links carry pets as a flag |
| region, property | scopes (region repeatable, OR-combined, applied in the browser) |
| sort, campaign | the results ordering; an offer campaign scope |
| policy, kind, return, offer | the chosen rate (rental → checkout), a booking kind, the payment-return marker, an offer label carried to the rental page |
The codec
SearchContract:{ backend, nightRangeSearch?, aliases? }.nightRangeSearch(defaulttrue): setfalseon a v9 hub that answers the night pair unpriced; no control writes it, nothing reads or sends itSearchParamAliases: legacy keys read as a canonical one (start,end,region,adults,children,childrenAges,babies,petsCount).from/till/nights_min/nights_maxare always readreadSearch(search, contract, today?): query string →SearchQuery(period,party,regions,carried). Tolerant: anything unreadable is "not stated". A v9 link with v10 flexible keys reads as no period; flexible months already past are droppedwriteSearch(query, contract, options?):SearchQuery→QueryPair[](WriteSearchOptions:floor,defaultAdults) spelled for the backend, in the order period, party, regions, carried. Dogs aspetsCount; a written period always travels with adults (defaultAdults, default 2: a dated v10 search without occupancy answers nothing).floor(default today) clamps the lower end of a ± windowsearchHref(action, query, contract, options?):action(may carry a#fragment) pluswriteSearchreadParty(params, aliases?): the party from any funnel URL:petsCountwins overpets,pets=truereads as 1, ages aligned tochildren;nullwithout any party keypartyPairs(party, petsKey): the party as pairs (petsKey"petsCount"or"pets"); zero counts omitted
SearchPeriod is a period by meaning; writeSearch spells it per backend:
| kind | fields | URL |
| ------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| exact | start, end | start + end |
| plusMinus | start, end (the guest's stay), days | v10: dates + nights + flex; v9: the widened start/end + minNights = maxNights + flex (needs nightRangeSearch) |
| nightRange | start, end, minNights, maxNights | start + end + the pair (the window alone where the pair is not priced) |
| flexible | months (IsoMonth[]), stay ("weekend" \| "week" \| "month") | v10 only: repeated month + weekend=1, nights=7, or the days of the earliest month |
import { readSearch, searchHref, type SearchContract } from "@v-office/website-headless";
const contract: SearchContract = {
backend: "v9",
aliases: { start: ["checkin"], end: ["checkout"] },
};
const query = readSearch(
"?checkin=2027-05-10&checkout=2027-05-17&adults=2&sort=preis-auf",
contract,
);
// query.period: { kind: "exact", start: "2027-05-10", end: "2027-05-17" }; query.carried: [["sort", "preis-auf"]]
const href = searchHref("/suchen", { ...query, regions: ["Nord"] }, contract);Guests
Module guests. One party model and its rules, shared by the search form, the quote and the
checkout. Babies and dogs never count against a person cap. A type whose limit is 0 is "not
offered": it renders no control and keeps whatever value it arrived with ("hidden means not offered,
never not honoured").
Party:{ adults, children, childrenAges: (number | null)[], babies, pets }.adults0 = none chosen yet; an age isnulluntil chosenGuestKind:"adults" | "children" | "babies" | "pets"GuestLimits,DEFAULT_GUEST_LIMITS: stepper maxima; defaults adults 16, children 6, babies 5, pets 4. 0 = not offered; pets 1 = a yes/no choiceChildAgeRange,DEFAULT_CHILD_AGE: the selectable child ages, default 2–17 (younger is a baby)GuestOptions:{ limits?: Partial<GuestLimits>, childAge?, defaultAdults? }(default adults 2): theGUESTSconstantGuestCaps: a rental's caps:maxPersons,maxAdults(availability) beatmaxGuests(catalogue, used where availability states none: v9 before SDK 2.28) beat the limits; a stated cap above the limit wins (a 20-person house).childrenAllowed: false(v9 availability) offers no children or babies;maxPets(v9 availability) caps dogs,0offers none;petsAllowed: false: no dog can be added. A count that arrived by link and one of these rules out can be lowered and is still sentguestBounds(party, { limits?, caps? }):GuestBounds: per kind aGuestBound(offered,min,max) (adults min 1). Adults win a shared person cap: children get what the adults leave. Compute the bounds from the party before an editclampParty(party, bounds): offered types clamped; a not-offered type keeps its value;adults0 stays 0; ages follow the children countagesComplete(party): every child has an age (the age prices the stay)occupancyOf(party): the SDK'sQuoteOccupancy; unchosen ages left outstayKeyOf({ start, end, party }): dates and party as one comparable string (supersession in the quote and checkout)
createGuestSearch(options?)
The party a guest is building, as a store (the search form composes one; a custom guest UI may use
it alone). Options: GuestOptions plus showDefault (default false: start at defaultAdults
when the URL states no party) and caps.
State: party, shownAdults (chosen, or defaultAdults: what the stepper and the summary show and
a submit sends), hasSelection, agesComplete (always true while children are not offered),
bounds, ageOptions, occupancy (what a submit sends). Actions: set(kind, count),
setAge(index, age | null), load(party | null) (ages outside the band become unchosen, so the
guest is asked again), reset(), update(options).
Search store and live search
Module search. The page's one live search: walk, scopes, browser filters, viewport, sort, campaign
and outcome. Grid, map, count, chips and filter islands read the same store, because the SDK queues
searches at 1 request per second.
createSearchStore(source, options)
options is a SearchStoreOptions; returns a SearchStore (Store<SearchState> plus
SearchActions and dispose()). It starts searching when created. It reads and writes the URL
only through the injected history port and never creates or disposes the SDK.
| option | default | meaning |
| ---------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| backend, nightRangeSearch, aliases | (the SearchContract) | backend is required |
| rentalHref | required | a RouteTemplate with {slug} or {id} for rentals the facts give no href |
| locale | required | the language of the PMS strings and the stay labels |
| history | required | HistoryPort: read(), write(search, "push" \| "replace"), subscribe(onChange) (components: browserHistory()) |
| seed | required | the random-sort seed, drawn once per page view |
| facts | none | RentalFactsMap: build-time facts that fill what the live projection lacks and answer the browser filters |
| filters | none | ResolvedFilters; without it only period and occupancy reach the SDK |
| scope | none | SearchScope (below) |
| favorites | none | a FavoritesPort (ids() + subscribe); required for scope.favorites. Favorites satisfies it |
| campaignOf | campaignGroup | offer label → campaign group |
| preset | none | applied (replace) when the URL carries no search of its own |
| requireFacts | false | drop rentals the build has no facts for (no detail page was built) |
| reveal | "complete" | "first-page": the first page shows at once; the browser sort, the outcome and the count wait for the rest |
| sdkMode | "default" | the mode the SDK was created in |
| leaveMs | FAVORITE_LEAVE_MS (280) | the favourites page's removal window; 0 = immediate (pass 0 under reduced motion) |
| now | () => new Date() | the clock (also sanitizeSearch's today) |
SearchScope narrows the one result set, applied in this order: ids (a fixed set from the build:
a theme, a property, a landing page) → favorites: true (the Merkliste; on v10 restricted
server-side) → offers (discounted results only; labels narrows to exact PMS labels, campaign
to a whole campaign, window: OffersWindowSpec makes the page search its offers window when the URL
carries no period). Then the browser filters and requireFacts, the URL's ?campaign=, the map
viewport and the sort.
When a request runs. On create the store sanitizes the URL (a past or unreadable stay is dropped
with replace), applies preset where the URL has no search, and the offers window where it has no
period. A new live search runs only when the SDK-facing part changes (period, occupancy, SDK filter
keys, an id restriction, a server sort while the set is incomplete). Facts filters, scopes, the
campaign and a browser sort re-derive from the loaded set without a request. A walk reads every page
(sequentially, capped at 100 pages); a v9 ± search also asks the guest's own dates exactly and
prefers those answers.
SearchState:
status:SearchStatus:"loading"until the first answer,"ready", or"error"only when nothing loadedcomplete: every page is in (false while a first-page reveal loads, and after a later page failed: what loaded stays)search,shape: the current query (sanitized, no?) and itsQueryShapescope:SearchScopeKind:"search","favorites","offers","ids"matched: after scopes, filters and the campaign, before the viewport: what the map plotsitems,count: after the viewport and the sort: what the list renders, and how many (leaving favourites excluded from the count)allAlternatives: every result is an alternative on an exact search (show the notice); waits forcompletesort,sortOptions: theSortKeyin the URL; every key with whether it can be chosen nowfilters,activeFilters,filterCounts: the resolved filters; the applied ones (chips, hidden included, led by a?property=or?region=scope the curation has no facet for: see Filters); live counts over the listed set (keyfor a flag,key=valuefor a facet value)offerLabels,campaign: distinct PMS offer labels of the scoped results (before?campaign=narrows); the campaign in scopeoutcome:SearchOutcome(error or empty with ways out), or null while results show or the walk has not settledunusedFilterKeys: keys the backend reported as having had no effecthighlighted,reloading,viewportActive,leaving: the hovered card; the map's 220 ms re-settle pulse; a viewport filter is active; favourites un-hearted a moment ago (still initems, animating out)
| action | what it does |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| setSearch(search, mode = "push") | change the query |
| setSort(key) | order in place (replace) |
| applyFilters(selection), removeFilter(filter), clearFilters() | the filter panel, a chip, "remove all" (push; hidden filters and implicit scopes included in clearFilters) |
| setViewport(ids \| null), setReloading(on) | the map's in-view ids (results without coordinates always stay) and its pulse (the map controller calls these) |
| releaseRegionScope() | drop ?region= (push) so neighbouring results come back |
| setCampaign(group \| null) | the campaign chip (replace); a ?campaign= the complete results cannot honour clears itself |
| setHighlighted(id \| null), retry() | the hovered card; re-run after an error |
| dispose() | stop listening to the URL and the Merkliste, drop a running walk (the SDK stays) |
import {
acquireSearchStore,
createSearchStore,
loadRentalFacts,
shareSdk,
type HistoryPort,
} from "@v-office/website-headless";
const history: HistoryPort = {
read: () => location.search,
write: (search, mode) => {
const url = `${location.pathname}${search ? `?${search}` : ""}${location.hash}`;
if (mode === "push") window.history.pushState(null, "", url);
else window.history.replaceState(window.history.state, "", url);
},
subscribe: (onChange) => {
window.addEventListener("popstate", onChange);
return () => window.removeEventListener("popstate", onChange);
},
};
const shared = shareSdk(SDK);
const facts = await loadRentalFacts("/rental-facts.json");
const { store, release } = acquireSearchStore("results", () =>
createSearchStore(shared.sdk, {
...SEARCH_CONTRACT,
locale: "de-DE",
rentalHref: "/unterkuenfte/{slug}",
history,
facts,
seed: Math.floor(Math.random() * 2 ** 31),
}),
);
const stop = store.subscribe((state) => {
if (state.status === "ready") console.log(`${state.count} results`);
});
// teardown: stop(); release(); shared.release();Page-shared stores
acquireSearchStore(key, create)→SearchStoreClaim({ store, release() }): the store underkey, created by the first caller. Every island of the page (grid, map, count, chips) claims the same key; the last release disposes it.connectSearchStore(key, onStore)→ detach: follow a store that may appear later (a satellite island hydrating before the results);onStore(null)when it is released.
Result items
SearchResultItem is one rental in the result list (grid, map and count share it): id, name,
location ("" when the feed states none), href (the site's page plus the handover query),
hrefWithoutDates, images (CardImage[], WebP where served), counts, highlights,
rentalType, price (StayPrice | null), stay (StayWindow | null: the priced stay when it is
not simply the searched dates), alternative, periods (AlternativePeriod[]), propertyId,
coords, rating (from the facts only).
runLiveSearch(sdk, search, options): one request with the caller's SDK →SearchPage(items,shape,cursor,hasNextPage,totalCount,unusedFilterKeys).LiveSearchOptions: the contract,rentalHref,locale,facts,filters,sort(default"recommended"),cursor,rentalIdsIn(v10). The store does not use it; it is for a one-off requestcatalogueResultItem(rental, options): a catalogue rental as a result item for a server-rendered listing (no price, no stay, a link without booking context)compareUndatedSearchOrder(a, b): the order an undated search returns, as far as a build can reproduce it (numeric id ascending on v9, code-unit order on v10), so the island's swap moves nothingresultCardSource(item, facts?): theCardSourceof one result: live fields from the item, catalogue fields from the factsResultLinkOptions:{ rentalHref, facts?, locale }
Queries and links
toSdkQuery(search, options): URL query →SdkSearchQuery(query,sort?,withheld). An allowlist: the period keys this backend executes, occupancy, and the resolved filters' SDK keys; every other key stays in the URL.SdkQueryOptions: the contract,sort,sdkFilterKeys,retiredKeysqueryShape(search, contract):QueryShape:exactDates,priced(a period and at least one adult: totals arrive, price sort possible),dated,nights,window,ownStay(the v9 ± stay asked as a second question),hasSearchhandoverQuery(search, options?):ResultHandoverOptions(stay,offerLabels). The?…a result link carries: the period (or the given stay), the party (dogs aspets), at most fouroffer=labels and only with a periodexternalSearchHref(link, search, contract): a link to a partner's search from the current one (ExternalSearchLink:base,params(partner name →ExternalSearchSource),dateFormat"iso","d.m.yyyy"or"dd.mm.yyyy"). Values without data are omitted. Throws on abasethat is not an absolute URLsanitizeSearch(search, today):SanitizedSearch(search,dropped): an unsearchable stay (past, reversed, unreadable, half a pair) dropped with its night range andflex, instead of an error pageresultsQueryFlagScript(): inline<head>script source that setshtml[data-results-query]before first paint when the URL carries a real search, so a prerendered listing never flashes under a search it does not answer
Sort
| SortKey | URL value (SORT_URL_VALUE) | runs |
| ------------- | ---------------------------- | --------------------------------------------------------------------------------------- |
| recommended | empfohlen | the backend's order; no sort sent |
| price-asc | preis-auf | priced queries only; sent to the SDK and re-applied in the browser over the merged list |
| price-desc | preis-ab | as price-asc |
| rating | bewertung | browser only, missing values last |
| area-desc | groesse | browser only |
| area-asc | groesse-auf | browser only |
| guests | gaeste | browser only |
| bedrooms | schlafzimmer | browser only |
| random | zufall | browser only, seeded per page view |
DEFAULT_SORT_OPTIONS:["recommended", "price-asc", "price-desc"], the short list for a site that does not choose its own. Any known value in the URL is honoured even when the control does not offer it.sortFromSearch(search)→SortKey("recommended"when absent or unknown).toSdkSort(key, shape)→ the SDK ordering, orundefined(price needs a priced query).sortResults(items, key, seed): stable, missing values last, never as 0.SortOptionState:{ key, available, reason?: "needs-price" | "needs-data" }.
Filters
The package ships no curation: which keys, groups and order a portfolio needs is measured per site. Every applied filter stays visible and removable; "hidden" means not offered as a control, never not honoured.
?region= (the search bar's region field) and ?property= (an Anlage link) narrow the results
even where the curation has no facet for them. The search store then lists them first in
state.activeFilters as facet chips with scope: "region" | "property" (ImplicitScope), an empty
label (the copy words them, "Region: {value}", "Anlage: {value}") and the URL value verbatim as
value; removeFilter and clearFilters remove them, and an empty result names them as the
"filters" cause. A curated (hidden) facet for the key replaces the implicit chip with its own label
and option labels, which is how a property chip shows the property's name instead of its id.
A FilterSpec is { groups: FilterGroupSpec[], retired?: string[] }; a group is
{ id, title, entries, hidden? }; an entry (FilterEntry) is one of:
| kind | answered by | fields |
| --------- | ----------------------------------------------------------------- | --------------------------------------------------------------- |
| sdk | the backend (getFilters: a boolean, or an int minimum) | key, label? (overrides the SDK's), max? (stepper maximum) |
| flag | the browser, from facts.flags[key] | key, label |
| minimum | the browser, from facts.counts[field] or facts.numbers[field] | key, label, field, min? (1), max |
| facet | the browser, OR within, from facts.facets[key] | key, label, options: FacetOption[], multiple? (true) |
| pets | occupancy petsCount | the dogs pill, placed where the entry sits |
Every entry and group may be hidden. retired keys are still executed by the backend but match
nothing here: they are stripped from every query, so an old link cannot empty the page invisibly.
resolveFilters(sdkFilters, spec, options?): the curation resolved againstsdk.static.filter.getFilters(its output passes as it is) →ResolvedFilters(groups,defs,sdkKeys,retired), serializable. An SDK key the SDK no longer returns self-hides; option and string filters in ansdkentry are skipped; int bounds default to 1..8.ResolveFiltersOptions:counts,total(frommeasureFilters),whenEmpty(default"drop"),whenUniversal(default"drop": a filter matching everything does not filter)measureFilters(sdk, keys, { locale, baseline? }): build time, opt-in: hits per boolean key against a baseline (default"adults=1"), one request per key, sequential →FilterMeasurement(total,counts). A count the backend does not state is left outfacetOptions(facts, key, options?): facet options from the build's facts with their counts (FacetOptionsOptions):order"label"(default),"count-desc"or an explicit list;labels;locale(collation, default"de-DE")readSelection(search, filters),writeSelection(search, filters, selection): the URL round trip of aFilterSelection(booleans,minimums,facets,pets). Writing rewrites visible defs only, keeps hidden ones, writes dogs aspetsCountand never lowers the URL's dog countactiveFilters(search, filters): the applied filters of the curation asActiveFilter[](chips;idiskeyorkey=value; the copy words them). The store'sstate.activeFiltersadds the implicit scopes (above)withoutFilter(search, filter),withoutFilters(search, filters): the query without one chip, or without every filter (dates, party and scopes kept)minimumSteps(def, value): a minimum stepper's{ down, up }: the first step up lands on the floor, the step down from the floor returns to "any"quickFilterDefs(filters, keys): the quick strip's defs inkeysorder (visible booleans only)FilterDef,SdkFilterDefinition,FacetOption,WhenEmpty,ImplicitScope: the resolved def union (boolean,minimum,facet,pets), the minimal shape read offgetFilters(every kind it returns), one facet value, the zero-match decision,"region" | "property"
import { resolveFilters, type FilterSpec } from "@v-office/website-headless";
const FILTER_SPEC: FilterSpec = {
groups: [
{
id: "features",
title: "Ausstattung",
entries: [
{ kind: "sdk", key: "sauna" },
{ kind: "flag", key: "seaView", label: "Meerblick" },
{ kind: "pets" },
],
},
{
id: "size",
title: "Größe",
entries: [
{ kind: "minimum", key: "bedrooms", label: "Schlafzimmer", field: "bedrooms", max: 6 },
],
},
],
};
// Build time, over the loaders' one SDK (see "Which SDK instance").
const filters = resolveFilters(
await sdk.static.filter.getFilters({ locale: "de-DE" }),
FILTER_SPEC,
);Outcome
describeOutcome(input: OutcomeInput) → SearchOutcome | null (null when there are results). The
store computes it; a custom UI reads state.outcome.
{ kind: "error" }, or{ kind: "empty", scope, causes, searched, relaxations }.scope:"search","favorites-none-saved","favorites-none-match","offers"(nothing reduced in this period, normal much of the year),"ids".causes(EmptyCause[], most actionable first):"filters","pets","dates","party"(more than 2 guests). An empty Merkliste has none.relaxations(Relaxation[]): one concrete query per cause, guaranteed to differ from the current one:"without-filters","without-pets","without-dates"(removes every period key),"fewer-guests"(2 adults).searched(SearchedParts):guests, `d
