@v-office/website-headless 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ HERE, so a site's own UI is held to the same contract.
10
10
  @v-office/sdk-core domain contracts + PMS adapters
11
11
  @v-office/website-sdk website facade (static + live namespaces)
12
12
  @v-office/website-headless ← THIS: orchestration hooks, validation, legal
13
- gates, derived manifest, enhancement engines
13
+ gates, enhancement engines
14
14
  @v-office/website-components imported UI over these hooks (a site may copy and own it)
15
15
  userland pages, brand components, tokens, copy
16
16
  ```
@@ -20,7 +20,6 @@ userland pages, brand components, tokens, copy
20
20
  | Import | Contents |
21
21
  | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
22
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
- | `…/manifest` | `siteManifest()` Astro integration — writes `dist/site-manifest.json` with installed package versions and per-surface states derived from the site's imports (`imported` = imports the component from website-components, `custom` = imports the surface's hook without it; see `manifest/surfaces.ts`). Three signals cross-checked per build. Node-only; import it in `astro.config` and nowhere else |
24
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 |
25
24
 
26
25
  The flows are stores: a UI calls `getState()`, `subscribe(listener)` and the
@@ -66,13 +65,13 @@ parameters — the rules live here, the words live in the site.
66
65
 
67
66
  ```bash
68
67
  pnpm install
69
- pnpm build # tsdown → dist/ (index, manifest, enhance; .mjs + .d.mts)
68
+ pnpm build # tsdown → dist/ (index, enhance; .mjs + .d.mts)
70
69
  pnpm check # tsc · oxlint · oxfmt --check
71
70
  pnpm test # vitest, jsdom environment
72
71
  ```
73
72
 
74
73
  The tests cover the pure rules (validation, insurance gate, stay helpers,
75
- quote adapter, manifest derivation), the checkout flow driven as a store
74
+ quote adapter), the checkout flow driven as a store
76
75
  against a fake SDK, and the structural gate against rendered markup. Nothing
77
76
  books or pays for real. vitest is used because the structural gate needs a
78
77
  DOM (jsdom); the SDKs use `node --test` because they do not.
@@ -15,7 +15,10 @@
15
15
  * what the iOS swipe momentum/snap relies on; transform-based
16
16
  * shifting can't reproduce it.
17
17
  *
18
- * Dots are optional and only updated when present.
18
+ * Dots are optional and only updated when present. Their accessible label
19
+ * follows `data-label-template` on the dots list (`{count}` images,
20
+ * `{current}` shown) — the component renders the template from its copy, so
21
+ * the enhancer carries no language of its own.
19
22
  */
20
23
  declare function initImageCarousels(selector?: string): void;
21
24
  declare function initRentalCarousel(viewport: HTMLElement): void;
@@ -15,7 +15,10 @@
15
15
  * what the iOS swipe momentum/snap relies on; transform-based
16
16
  * shifting can't reproduce it.
17
17
  *
18
- * Dots are optional and only updated when present.
18
+ * Dots are optional and only updated when present. Their accessible label
19
+ * follows `data-label-template` on the dots list (`{count}` images,
20
+ * `{current}` shown) — the component renders the template from its copy, so
21
+ * the enhancer carries no language of its own.
19
22
  */
20
23
  function initImageCarousels(selector = ".card-images") {
21
24
  document.querySelectorAll(selector).forEach((el) => {
@@ -49,7 +52,8 @@ function initImageCarousels(selector = ".card-images") {
49
52
  d.removeAttribute("data-active");
50
53
  }
51
54
  });
52
- dotsList.setAttribute("aria-label", `${total} Bilder, gerade angezeigt: ${idx + 1} von ${total}`);
55
+ const template = dotsList.dataset.labelTemplate;
56
+ if (template) dotsList.setAttribute("aria-label", template.replace(/\{current\}/g, String(idx + 1)).replace(/\{count\}/g, String(total)));
53
57
  };
54
58
  const goTo = (idx) => {
55
59
  const clamped = Math.max(0, Math.min(total - 1, idx));
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { BookingPaymentOption, BookingPaymentScheduleKind, GuestQuote, GuestQuoteResult, SubmittableBookingPaymentOption, createWebsiteSDK } from "@v-office/website-sdk";
1
+ import { BookingPaymentOption, BookingPaymentScheduleKind, GuestQuote, GuestQuoteResult, Locale, SearchSearchSort, SubmittableBookingPaymentOption, WebsiteSDKOptions, createWebsiteSDK } from "@v-office/website-sdk";
2
2
  //#region src/store/index.d.ts
3
3
  /**
4
4
  * The smallest external store that every UI framework can subscribe to:
@@ -22,6 +22,50 @@ declare function createStore<S>(initial: S): WritableStore<S>;
22
22
  //#endregion
23
23
  //#region src/sdk.d.ts
24
24
  type WebsiteSdk = ReturnType<typeof createWebsiteSDK>;
25
+ /** Deployment stage: selects the public endpoint set below. */
26
+ declare const STAGES: readonly ["development", "production"];
27
+ type Stage = (typeof STAGES)[number];
28
+ /** Public endpoints of the v10 backend per stage. */
29
+ declare const VOFFICE_ENDPOINTS: Record<Stage, {
30
+ apiEndpoint: string;
31
+ imageBaseUrl: string;
32
+ searchEndpoint: string;
33
+ }>;
34
+ /** Public endpoints of the v9 backend per stage; API URLs derive from `apiBaseUrl`. */
35
+ declare const HUB_ENDPOINTS: Record<Stage, {
36
+ apiBaseUrl: string;
37
+ imageProxyBaseUrl: string;
38
+ }>;
39
+ /** Everything the SDK needs, as plain public values — a static site has no
40
+ * secret to protect. Serializable on purpose: an Astro island receives it as a
41
+ * prop, so the site reads its env once (in .astro / a loader) and never inside
42
+ * a component. */
43
+ type SdkConfig = {
44
+ backend: "v9";
45
+ imageProxyBaseUrl: string;
46
+ v0ApiBaseUrl: string;
47
+ v1ApiBaseUrl: string;
48
+ graphqlUrl: string;
49
+ apiKey: string;
50
+ } | {
51
+ backend: "v10";
52
+ imageBaseUrl: string;
53
+ apiEndpoint: string;
54
+ searchEndpoint: string;
55
+ accessToken: string;
56
+ };
57
+ /** The three values a site actually decides — backend, stage, its token — as
58
+ * a full `SdkConfig`, with the endpoints the platform owns. */
59
+ declare function sdkConfigFor({ backend, stage, token }: {
60
+ backend: SdkConfig["backend"];
61
+ stage: Stage;
62
+ token: string;
63
+ }): SdkConfig;
64
+ /** Build an SDK instance from a config (+ the SDK's options: translation
65
+ * overrides, custom attributes, highlight prioritization). The caller owns the
66
+ * lifecycle and calls `await sdk.dispose()` when done. The API is rate-limited
67
+ * (1/s), so a browser surface keeps ONE instance for its lifetime. */
68
+ declare function createSdk(config: SdkConfig, options?: WebsiteSDKOptions): WebsiteSdk;
25
69
  /**
26
70
  * Render `error` and its whole cause chain as indented JSON.
27
71
  *
@@ -288,7 +332,7 @@ declare function adaptGuestQuote(quote: GuestQuote): QuoteView;
288
332
  declare function fetchQuote(rentalId: string, period: {
289
333
  start: string;
290
334
  end: string;
291
- }, occupancy: QuoteOccupancy, sdk: WebsiteSdk, voucher?: string): Promise<QuoteResult>;
335
+ }, occupancy: QuoteOccupancy, sdk: WebsiteSdk, locale: Locale, voucher?: string): Promise<QuoteResult>;
292
336
  type QuoteSelectionResult = {
293
337
  ok: true;
294
338
  quote: GuestQuote;
@@ -459,7 +503,7 @@ interface BookingConfirmation {
459
503
  * real. Throws on transport/backend errors (the caller renders an honest
460
504
  * retry message; nothing was necessarily booked when it throws).
461
505
  */
462
- declare function submitBooking(quote: GuestQuote, contact: BookingContactForm, sdk: WebsiteSdk): Promise<BookingConfirmation>;
506
+ declare function submitBooking(quote: GuestQuote, contact: BookingContactForm, sdk: WebsiteSdk, returnPath?: string): Promise<BookingConfirmation>;
463
507
  /** True for the option kinds `submitPaymentOption` can perform in-browser
464
508
  * (redirect / form_post / stripe_checkout). Bank transfer is display-only. */
465
509
  declare function isSubmittableOption(option: BookingPaymentOption): option is SubmittableBookingPaymentOption;
@@ -728,6 +772,13 @@ interface CheckoutFlowOptions {
728
772
  code: QuoteUnavailableCode;
729
773
  rentalId: string;
730
774
  }) => void) | undefined;
775
+ /** The SDK locale: the PMS answers in that language (quote lines, reasons,
776
+ * policy names). */
777
+ locale: Locale;
778
+ /** Where the payment provider sends the guest back to (relative to the
779
+ * site's origin). Default: the platform's `/buchen/bestaetigung`; a site
780
+ * with its own return route passes it here. */
781
+ returnPath?: string | undefined;
731
782
  }
732
783
  interface CheckoutIssue {
733
784
  /** The stayKey the issue belongs to — a different stay re-arms the loop. */
@@ -818,18 +869,19 @@ interface ValidationMessages {
818
869
  postalcodeInvalidCH: string;
819
870
  streetHouseNumberWarning: string;
820
871
  }
821
- declare function normalizePhone(raw: string, dial?: string): string;
822
- declare function phoneError(raw: string, dial: string, c: ValidationMessages): string | undefined;
872
+ declare function normalizePhone(raw: string, dial: string | undefined): string;
873
+ declare function phoneError(raw: string, dial: string | undefined, c: ValidationMessages): string | undefined;
823
874
  declare function emailError(raw: string, c: ValidationMessages): string | undefined;
824
875
  declare function postalcodeError(raw: string, country: string, c: ValidationMessages): string | undefined;
825
876
  declare function streetWarning(raw: string, c: ValidationMessages): string | undefined;
826
877
  declare const VALIDATED_FIELDS: readonly ["forename", "surname", "email", "phone", "street", "postalcode", "city"];
827
878
  /** Validate one named field. Takes the selected COUNTRY plus its calling code
828
- * (`dial`): two validators depend on the country — the phone via the dial
829
- * code, the PLZ via its format rule. The dial code is passed in rather than
830
- * looked up because the ISO→dial mapping lives with the site's country
831
- * register (its localized "Land" options), not here. */
832
- declare function fieldError(name: string, value: string, country: string, dial: string, c: ValidationMessages): string | undefined;
879
+ * (`dial`, undefined when the register has none for it): two validators depend
880
+ * on the country — the phone via the dial code, the PLZ via its format rule.
881
+ * The dial code is passed in rather than looked up because the ISO→dial
882
+ * mapping lives with the site's country register (its localized "Land"
883
+ * options), not here. */
884
+ declare function fieldError(name: string, value: string, country: string, dial: string | undefined, c: ValidationMessages): string | undefined;
833
885
  /** Country options in the shape the site's country register provides —
834
886
  * `[code, label]` pairs, a featured group plus the full ISO register. Kept
835
887
  * structural so the site's own `CountryGroups` type satisfies it. */
@@ -909,4 +961,380 @@ interface StructureViolation {
909
961
  }
910
962
  declare function assertCheckoutStructure(root: ParentNode): StructureViolation[];
911
963
  //#endregion
912
- export { BOOKING_SNAPSHOT_KEY, BUTTON_SOLUTION_PATTERN, BookingConfirmation, BookingContactForm, BookingSnapshot, BookingSnapshotGuests, CHECKOUT_CONTEXT_KEY, CHECKOUT_PARTS, CHECKOUT_RETURN_PATH, CHECKOUT_STRUCTURE, CheckoutContext, type CheckoutFlow, type CheckoutFlowActions, type CheckoutFlowApi, type CheckoutFlowMessages, type CheckoutFlowOptions, type CheckoutFlowState, type CheckoutIssue, type CheckoutPart, type CheckoutPhase, type CountryGroupsLike, type InsuranceDocsState, InsurancePaymentLink, Listener, MULTI_RATE_CODES, NO_INSURANCE_DOCS, PaymentLine, PaymentScheduleView, PolicyBranch, PriceQuote, QuoteAddonService, QuoteCancellationPolicy, QuoteInsurance, QuoteInsuranceOption, QuoteLine, QuoteOccupancy, QuotePolicyRule, QuoteResult, QuoteSelectionResult, QuoteUnavailableCode, QuoteView, QuoteVoucher, SnapshotImage, type Stay, Store, type StructureViolation, Unsubscribe, VALIDATED_FIELDS, type ValidationMessages, WebsiteSdk, WritableStore, adaptGuestQuote, addQuoteService, assertCheckoutStructure, clearQuoteServices, createCheckoutFlow, createStore, describeSdkError, detectCountry, emailError, fetchQuote, fieldError, freeCancellationUntil, fullRefundRule, insuranceDocsPending, insuranceIdToReapply, insuranceProductKey, isRateConfigurationFault, isSubmittableOption, isoDate, logSdkFailure, normalizePhone, occupancyOf, payWithOption, phoneError, policyFreeCancellationUntil, postalcodeError, pricePolicyBranches, removeQuoteService, saveBookingSnapshot, saveCheckoutContext, selectQuoteInsurance, selectQuotePolicy, selectableInsuranceOptions, selectedInsuranceOption, shownInsuranceResources, stabilizeCardOrder, stayKeyOf, stayParams, streetWarning, submitBooking };
964
+ //#region src/dates.d.ts
965
+ declare function parseParam(value: string | null | undefined): Date | undefined;
966
+ declare function parseGermanParam(value: string | null | undefined): Date | undefined;
967
+ /** A flexible-length stay ("3–5 Nächte") as the URL carries it. Off until the
968
+ * search UI offers it; the keys are reserved so a site can enable it. */
969
+ declare const NIGHT_RANGE_ENABLED = false;
970
+ interface NightRange {
971
+ min: number;
972
+ max: number;
973
+ }
974
+ declare const NIGHT_RANGE_KEYS: readonly ["minNights", "maxNights", "nights_min", "nights_max"];
975
+ declare function parseNightRange(params: URLSearchParams): NightRange | null;
976
+ //#endregion
977
+ //#region src/format.d.ts
978
+ /**
979
+ * Display tidy for an SDK pre-formatted German-locale price ("1.234,00 €").
980
+ * Nearly every stay is whole euros, so the trailing ",00" is noise — dropped only
981
+ * when it is the zero-cents group at the end, so real cents ("1.234,50 €") stay.
982
+ * A real value formatted for display, nothing fabricated; other formats pass.
983
+ */
984
+ declare function tidyPrice(formatted: string): string;
985
+ //#endregion
986
+ //#region src/availability.d.ts
987
+ /**
988
+ * The per-night price a calendar CELL shows, rounded to whole euros:
989
+ * "116,43 €" -> "116 €", "116,50 €" -> "117 €".
990
+ *
991
+ * A month grid is thirty-odd numbers seen at a glance and acted on by NONE of
992
+ * them — the guest picks a range, and the quote under the calendar then states
993
+ * the stay to the last cent. Two decimals in every cell buy precision nobody
994
+ * reads, at the one size where the digits are already tight.
995
+ *
996
+ * ROUNDS TO NEAREST, AND THAT IS THE POINT OF THE FUNCTION. Stripping the cents
997
+ * with a regex is one line and understates every priced night by up to 99
998
+ * cents, always in the direction that flatters the price. That is the shape of
999
+ * claim a host has to stand behind, so the error goes both ways here and is
1000
+ * capped at 50 cents on a figure that is explicitly indicative.
1001
+ *
1002
+ * Parses the German format rather than doing string surgery on it: "." groups
1003
+ * thousands, "," is the decimal. Anything that does not match that shape —
1004
+ * another locale, no decimal group, a price the SDK formatted differently — is
1005
+ * returned UNCHANGED. A display tidy must never turn a price it did not
1006
+ * understand into a wrong one.
1007
+ *
1008
+ * This runs where `priceByDay` is BUILT rather than where a cell renders it, so
1009
+ * every calendar fed from this map agrees: the widget's picker and the
1010
+ * checkout's copy, which reads the same shape.
1011
+ */
1012
+ declare function nightlyPrice(formatted: string, locale?: Locale): string;
1013
+ type VofficeSdk$1 = WebsiteSdk;
1014
+ interface InitialAvailability {
1015
+ /** ISO dates (`YYYY-MM-DD`) the SDK flags as a valid check-in/arrival day. */
1016
+ canStart: Set<string>;
1017
+ /** ISO dates the rental is genuinely unbookable on (booked / closed / past the
1018
+ * bookable horizon — the SDK's "hard" statuses). The picker STRIKES these
1019
+ * through. A disabled day NOT in this set is only blocked by a rule for the
1020
+ * current phase (e.g. check-in-not-allowed) and is merely dimmed — it could
1021
+ * work in another context. */
1022
+ hardBlocked: Set<string>;
1023
+ /** Per-night price by ISO date — the SDK's localized string, display-tidied
1024
+ * to drop the noise ",00" (e.g. "120 €"). Only days the SDK prices. */
1025
+ priceByDay: Map<string, string>;
1026
+ /** Localized reason a day is blocked, by ISO date (the SDK's own `tooltip`,
1027
+ * e.g. "An diesem Tag ist keine Anreise möglich."). */
1028
+ tooltipByDay: Map<string, string>;
1029
+ /** Occupancy caps the SDK states for this rental (null when omitted). The
1030
+ * picker caps the guest stepper at these so a quote can't fail on too many
1031
+ * guests; the below-minimum case is left to the quote (the SDK is the
1032
+ * arbiter of a bookable party), so `minPersons` isn't surfaced here. */
1033
+ maxPersons: number | null;
1034
+ maxAdults: number | null;
1035
+ }
1036
+ interface EndAvailability {
1037
+ /** ISO dates the SDK flags as a valid check-out/departure for the chosen
1038
+ * start (min-stay + checkout rules already applied by the SDK). */
1039
+ canEnd: Set<string>;
1040
+ /** ISO dates genuinely unbookable (booked / the range would cross an
1041
+ * unavailable night that is itself unavailable). STRUCK through. A disabled
1042
+ * day not here is only rule-blocked for this start (min-stay, check-out-not-
1043
+ * allowed, same-day, would-be a new start) and is merely dimmed. */
1044
+ hardBlocked: Set<string>;
1045
+ /** Localized reason a departure is blocked, by ISO date (min-stay-not-met,
1046
+ * check-out-not-allowed, range-crosses-booked, …) — the SDK's own `tooltip`. */
1047
+ tooltipByDay: Map<string, string>;
1048
+ }
1049
+ /** Possible check-in days + occupancy caps for `rentalId` across the window.
1050
+ * The caller's SDK instance is reused so its 30s per-rental cache spans the
1051
+ * picker's lifetime. */
1052
+ declare function fetchInitialAvailability(sdk: VofficeSdk$1, rentalId: string, calculateFromDate: string, calculateThroughDate: string, locale: Locale): Promise<InitialAvailability>;
1053
+ /** Possible check-out days for a chosen `selectedStartDate`. */
1054
+ declare function fetchEndAvailability(sdk: VofficeSdk$1, rentalId: string, calculateFromDate: string, calculateThroughDate: string, selectedStartDate: string, locale: Locale): Promise<EndAvailability>;
1055
+ //#endregion
1056
+ //#region src/search.d.ts
1057
+ type VofficeSdk = WebsiteSdk;
1058
+ interface SearchImage {
1059
+ src: string;
1060
+ srcset?: string | undefined;
1061
+ alt: string;
1062
+ }
1063
+ /** One window the SDK suggests for a rental it could not match exactly — a
1064
+ * concrete, priced, bookable stay.
1065
+ *
1066
+ * EVERY FIELD IS READ OFF THE PERIOD THE SDK SENDS, never off the rental. A
1067
+ * promotion belongs to a (rental x window) pair — the same rental prices
1068
+ * undiscounted in another window — so the price and the discount are read from
1069
+ * the same period together, the way `mapAlternativeEntry` already resolved them
1070
+ * for the single window it used to render. A window whose dates the feed omits
1071
+ * has no label and never reaches this list; one the feed does not price shows
1072
+ * no price rather than a placeholder. */
1073
+ interface AlternativePeriod {
1074
+ /** „31. Aug - 4. Sep", composed from this window's own start and end. */
1075
+ label: string;
1076
+ /** The detail-page link carrying THIS window's dates, so the rental opens on
1077
+ * the stay the row names rather than on the user's (unmatched) query. */
1078
+ href: string;
1079
+ /** This window's `formattedTotal`, verbatim. null when the feed omits it. */
1080
+ priceLabel: string | null;
1081
+ /** This window's length in nights. null when the feed omits a date. */
1082
+ nights: number | null;
1083
+ /** True when THIS window is discounted — the presence of the SDK's `discount`
1084
+ * block on this period, nothing inferred. Carried so the three discount facts
1085
+ * arrive together from `mapDiscount`, exactly as they do on the card. */
1086
+ isOffer: boolean;
1087
+ /** This window's pre-discount total, for the struck-through "was" price. */
1088
+ offerOriginalPriceLabel: string | null;
1089
+ /** This window's promotions, the PMS's own labels, verbatim. `amount` is
1090
+ * LINE-level and must not be printed beside a total — see the note on
1091
+ * `SearchResultItem.offers`. */
1092
+ offers: {
1093
+ label: string;
1094
+ amount: string | null;
1095
+ }[];
1096
+ }
1097
+ interface SearchResultItem {
1098
+ /** PMS id — shared by the result card and its map marker. */
1099
+ id: string;
1100
+ /** `nameOrLabel`, verbatim. */
1101
+ name: string;
1102
+ /** `address.city`, verbatim (empty when the feed omits it). */
1103
+ location: string;
1104
+ /** Internal detail link, derived from the name (URL generation), with the
1105
+ * user's booking context (dates + party size) appended as a query so the
1106
+ * search selection carries into the rental page. */
1107
+ href: string;
1108
+ images: SearchImage[];
1109
+ /** Parsed out of the SDK highlights; 0 when the highlight is absent. */
1110
+ guests: number;
1111
+ bedrooms: number;
1112
+ area: number;
1113
+ /** Plain-text amenity highlights (the SDK has no icon concept), CARRIED WHOLE.
1114
+ *
1115
+ * This used to be `highlights.slice(3, 6)` — the SDK opens `highlights` with
1116
+ * the spec stats the line above already prints, and three was a guess at how
1117
+ * many. It is not a constant: how many spec lines a feed states depends on
1118
+ * which of them the PMS has filled and on `rentalHighlightPrioritization`, so
1119
+ * a feed stating four leaked „55 m²" into the pill row, while the same slice
1120
+ * ALSO discarded good candidates below position 6.
1121
+ *
1122
+ * The CARD decides now: `rental-detail-kit`'s `cardHighlights` drops the spec
1123
+ * block by SHAPE, then `pillRow` merges and caps. A consumer that renders this
1124
+ * array raw must cap it itself — it is no longer bounded here. */
1125
+ pills: string[];
1126
+ /** `formattedTotal`, verbatim (null when the feed omits a price). */
1127
+ priceLabel: string | null;
1128
+ /** Label for an SDK-provided alternative period, null for exact matches.
1129
+ * The FIRST of `alternativePeriods` — the SDK returns them best-first — kept
1130
+ * as its own field because the card's date pill names one window even where
1131
+ * the chooser lists several. */
1132
+ alternativePeriodLabel: string | null;
1133
+ /** EVERY window the SDK suggests for this card, best-first — not just the one
1134
+ * `alternativePeriodLabel` names. Empty on exact-match cards.
1135
+ *
1136
+ * THE FEED STATES MORE THAN ONE AND THIS PROJECT RENDERED ONE. Measured
1137
+ * against v10 production on 2026-08-27, query
1138
+ * `start=2026-08-31&end=2026-09-02&adults=2`: 19 alternative entries, and 14
1139
+ * of them carried TWO priced windows. `mapAlternativeEntry` read
1140
+ * `alternativePeriods[0]` and dropped the rest, so on three cards in four a
1141
+ * real, priced, bookable window existed that the guest was never shown. The
1142
+ * same query's flexible sibling (`month=10-2026&nights=7`) returned exactly
1143
+ * one window on all 20 entries — so the count is a property of the QUERY, not
1144
+ * a constant, and every surface reading this must handle both. */
1145
+ alternativePeriods: AlternativePeriod[];
1146
+ /** True only when the SDK flags this item as a non-exact date match. */
1147
+ isAlternativePeriod: boolean;
1148
+ /** Stay length for THIS card's own window, in nights. Set only on
1149
+ * alternative-period cards (whose date pill describes the SDK's suggested
1150
+ * window, not the user's search) so the footer's "N Nächte" matches the dates
1151
+ * shown. null on exact-match cards — they fall back to the page-level
1152
+ * `nights` (their window IS the user's search). */
1153
+ nights: number | null;
1154
+ /** True when THIS card's window is discounted ("Angebot" badge) — the mere
1155
+ * PRESENCE of the SDK's `discount` block on the priced result, nothing
1156
+ * inferred. A promotion belongs to a (rental × window) PAIR, not to a rental:
1157
+ * the same rental prices undiscounted in another window, so this travels with
1158
+ * the price, not with the identity. Never derived from a lead-time threshold —
1159
+ * that number is the customer's PMS setting and moves. */
1160
+ isOffer: boolean;
1161
+ /** `discount.formattedOriginalTotal`, verbatim — the pre-discount total, for
1162
+ * the struck-through "was" price. null when the feed states a discount but no
1163
+ * original total: presence of a discount and the ability to price it are
1164
+ * different facts, so the "was" price gates on its own field. */
1165
+ offerOriginalPriceLabel: string | null;
1166
+ /** `discount.applied`, verbatim — the PMS's own name for each promotion and
1167
+ * its amount. CARRIED, NEVER AUTHORED: the label names the offer ("Last-Minute
1168
+ * Angebot" is what this feed states today); writing our own over it would be
1169
+ * fabricated data. Empty when the feed states a discount it doesn't itemise —
1170
+ * `isOffer` can be true with nothing here, so the label row gates on length.
1171
+ *
1172
+ * ⚠ `amount` IS LINE-LEVEL, AND MUST NOT BE SHOWN BESIDE A TOTAL. It is the
1173
+ * modifier on the rent line, while `offerOriginalPriceLabel` and `priceLabel`
1174
+ * are totals — so wherever the PMS puts taxes or fees outside the discounted
1175
+ * line, the three do not reconcile. Measured on a live v10 quote of the same
1176
+ * stay the search priced: Inklusivpreis 484,35 € − 72,87 € = 411,48 €, plus
1177
+ * Steuern & Abgaben 10,77 €, total 422,25 €, against a search
1178
+ * `formattedOriginalTotal` of 497,12 €. Both endpoints agree on every figure;
1179
+ * the real total saving is 74,87 € because the tax shrank too, while `amount`
1180
+ * states the rent's 72,87 €. `search-results-kit` renders the LABEL alone for
1181
+ * this reason — it did render the amount, in 1.10.0, and that was the bug.
1182
+ * The field STAYS because it is correct DATA: a surface that shows the rent
1183
+ * line itself may pair it with that line. Do not "fix" it by computing
1184
+ * `original − total` — that number is nowhere in the feed. */
1185
+ offers: {
1186
+ label: string;
1187
+ amount: string | null;
1188
+ }[];
1189
+ /** True when the rental is newly listed ("Neu" badge). The live search summary
1190
+ * feed carries no listing-age signal — the SDK's search result
1191
+ * (`toSearchOutputItem`) exposes only id/name/timeZone/address/images/
1192
+ * highlights/formattedTotal, with no created/published/firstListed field — so
1193
+ * this is always false today and the badge self-hides; wire it to a real SDK
1194
+ * source (e.g. a per-rental created/published date) when search exposes one.
1195
+ * Never fabricate it — straight-through rule. */
1196
+ isNew: boolean;
1197
+ /** `property.id`, verbatim (null when the rental belongs to no property). The
1198
+ * v-office "Property" is the Anlage/complex a rental sits in; the home page's
1199
+ * Anlage cards link to `/suchen?property=<id>`, and the shared search store
1200
+ * scopes its snapshot to this id client-side (the live search has no property
1201
+ * filter key — verified: `property=` lands in `unusedFilterKeys`). Same
1202
+ * scoping model as favorites mode. */
1203
+ propertyId: string | null;
1204
+ /** `location`, verbatim (null when the feed omits coordinates). */
1205
+ coords: {
1206
+ lat: number;
1207
+ lng: number;
1208
+ } | null;
1209
+ }
1210
+ interface SearchPayload {
1211
+ items: SearchResultItem[];
1212
+ /** Stay length from the user's start/end query, or null when undated. */
1213
+ nights: number | null;
1214
+ /** Count of SDK-provided alternative-period results in this page. */
1215
+ alternativePeriodCount: number;
1216
+ /** `pageInfo.nextCursor`, verbatim — opaque, passed straight back to fetch
1217
+ * the next page. null when the feed omits it (last page / no more). */
1218
+ cursor: string | null;
1219
+ /** `pageInfo.hasNextPage`, verbatim. */
1220
+ hasNextPage: boolean;
1221
+ }
1222
+ /**
1223
+ * Run a live search for the given URL query string (e.g.
1224
+ * `window.location.search`). Builds the SDK query from the picker params,
1225
+ * calls the rate-limited live API once, and maps the items. Pass an SDK
1226
+ * instance to reuse its internal cache across an eager pagination walk;
1227
+ * otherwise this function owns a short-lived instance for a single call.
1228
+ *
1229
+ * Pass `cursor` (the previous page's `pageInfo.nextCursor`, treated as
1230
+ * opaque) to fetch the next page. The returned `cursor`/`hasNextPage` are
1231
+ * the SDK's `pageInfo` straight through, so the caller can keep paging.
1232
+ */
1233
+ declare function runLiveSearch(sdk: VofficeSdk, searchString: string, locale: Locale, cursor?: string | null): Promise<SearchPayload>;
1234
+ //#endregion
1235
+ //#region src/sort.d.ts
1236
+ /** URL param. A client-side scope like `property`/`region`, NOT an SDK query
1237
+ * key — it is dropped from the SDK query string in search.ts's DROP_KEYS and
1238
+ * travels as the separate `sort` input instead. */
1239
+ declare const SORT_PARAM = "sort";
1240
+ type SortValue = "empfohlen" | "preis-auf" | "preis-ab";
1241
+ /** The backend's own order. Emits no `sort` at all, so it is exactly the
1242
+ * ordering the page has always shown. Typed as the literal (not `SortValue`)
1243
+ * so `value === DEFAULT_SORT` narrows the rest away for `SDK_SORT`. */
1244
+ declare const DEFAULT_SORT: "empfohlen";
1245
+ interface SortOption {
1246
+ value: SortValue;
1247
+ label: string;
1248
+ /** Needs a period + occupancy, or the SDK rejects it before transport. */
1249
+ pricedOnly?: boolean;
1250
+ }
1251
+ declare const SORT_OPTIONS: readonly SortOption[];
1252
+ /** The sort in the URL, or the default when absent/unknown. */
1253
+ declare function activeSort(search: string): SortValue;
1254
+ /**
1255
+ * Can this query be priced? Mirrors the SDK's own precondition: a fixed period
1256
+ * (`start`+`end`) or a flexible window (`dates`/`month`/`weekend`), AND
1257
+ * occupancy. Without both, the backend returns no totals and the SDK rejects
1258
+ * price ordering before it reaches the wire.
1259
+ *
1260
+ * Period keys count only with a value: the search's `buildQuery` drops empty
1261
+ * params (`?start=&end=`), so a key that is merely present would pass this
1262
+ * gate and still reach the SDK undated. `weekend` is the exception — it is a
1263
+ * presence-only flag on both sides.
1264
+ */
1265
+ declare function isPricedQuery(search: string): boolean;
1266
+ /** Is this option selectable for the current query? */
1267
+ declare function isSortAvailable(option: SortOption, search: string): boolean;
1268
+ /**
1269
+ * The `sort` to hand the SDK for this URL, or `undefined` for the backend's
1270
+ * own order. Falls back to `undefined` (rather than throwing) when a price
1271
+ * sort survives in the URL after the dates were cleared.
1272
+ */
1273
+ declare function resolveSdkSort(search: string): SearchSearchSort | undefined;
1274
+ //#endregion
1275
+ //#region src/filters.d.ts
1276
+ /**
1277
+ * The search filter model the UI consumes — client-safe, no SDK import. The SDK
1278
+ * exposes filter definitions via `getFilters`; a site curates them at build time
1279
+ * into `FilterDef[]` (which keys, in which order) and hands the list to the
1280
+ * results surface. Boolean filters toggle, integer filters are a lower bound.
1281
+ */
1282
+ type FilterType = "boolean" | "int";
1283
+ interface FilterDef {
1284
+ /** The URL query key, which is also the SDK's search parameter key. */
1285
+ key: string;
1286
+ label: string;
1287
+ type: FilterType;
1288
+ /** Only meaningful for `int` filters. */
1289
+ min: number;
1290
+ max: number;
1291
+ }
1292
+ interface ActiveFilter {
1293
+ key: string;
1294
+ label: string;
1295
+ }
1296
+ /** A boolean filter is active when its param is present and truthy. */
1297
+ declare const isBooleanFilterActive: (value: string) => boolean;
1298
+ /**
1299
+ * The filters currently applied in the query, in the order the defs are
1300
+ * declared — the removable chips. An `int` label is prefixed with the localized
1301
+ * "minimum" wording, because its value is a lower bound.
1302
+ */
1303
+ declare function activeFiltersFromQuery(searchParams: URLSearchParams, defs: readonly FilterDef[], minimumPrefix: string): ActiveFilter[];
1304
+ /** The dog-friendly search as the URL carries it: `pets=true` (or a count) and,
1305
+ * from an older vocabulary, `petsCount=N`. Read by the live search. */
1306
+ declare const PETS_PARAM = "pets";
1307
+ declare const PETS_COUNT_PARAM = "petsCount";
1308
+ declare function petsCountInQuery(search: string): number;
1309
+ //#endregion
1310
+ //#region src/regions.d.ts
1311
+ /**
1312
+ * The "Region" scope of the live search.
1313
+ *
1314
+ * "Region" is a v10 custom attribute (MULTI_SELECT, PUBLIC) set per rental in
1315
+ * v-office — e.g. Duhnen, Döse. The live search has no usable server-side key
1316
+ * for it: the SDK filters a custom attribute only on an exact raw match of its
1317
+ * stored JSON-array value, and registering the definition would fold a
1318
+ * `Region: ["…"]` string into every card and detail highlight (verified). So
1319
+ * `region` is dropped from the SDK query (search.ts, DROP_KEYS) and applied
1320
+ * here, client-side, against the per-rental regions the site hydrates from its
1321
+ * static catalogue at build time — the same channel as the other facts the
1322
+ * search projection does not carry.
1323
+ */
1324
+ /** URL query key carrying the active region scope. Repeatable: several
1325
+ * `region=<name>` values OR together (show rentals in ANY selected region). */
1326
+ declare const REGION_PARAM = "region";
1327
+ /** The regions active in a URL query string: the `region` values, trimmed,
1328
+ * de-duplicated, order preserved. Absent or empty → `[]`. */
1329
+ declare function activeRegions(search: string): string[];
1330
+ /**
1331
+ * The results inside the region scope. A rental may carry several regions
1332
+ * (MULTI_SELECT) and matches when any of them is selected; with no active
1333
+ * region every result stays. A rental the map does not know is outside every
1334
+ * region, so it drops out of a scoped search rather than passing unseen.
1335
+ */
1336
+ declare function scopeToRegions<T extends {
1337
+ id: string;
1338
+ }>(items: readonly T[], active: readonly string[], regionsByRentalId: (id: string) => readonly string[] | undefined): T[];
1339
+ //#endregion
1340
+ export { ActiveFilter, AlternativePeriod, BOOKING_SNAPSHOT_KEY, BUTTON_SOLUTION_PATTERN, BookingConfirmation, BookingContactForm, BookingSnapshot, BookingSnapshotGuests, CHECKOUT_CONTEXT_KEY, CHECKOUT_PARTS, CHECKOUT_RETURN_PATH, CHECKOUT_STRUCTURE, CheckoutContext, type CheckoutFlow, type CheckoutFlowActions, type CheckoutFlowApi, type CheckoutFlowMessages, type CheckoutFlowOptions, type CheckoutFlowState, type CheckoutIssue, type CheckoutPart, type CheckoutPhase, type CountryGroupsLike, DEFAULT_SORT, EndAvailability, FilterDef, FilterType, HUB_ENDPOINTS, InitialAvailability, type InsuranceDocsState, InsurancePaymentLink, Listener, type Locale, MULTI_RATE_CODES, NIGHT_RANGE_ENABLED, NIGHT_RANGE_KEYS, NO_INSURANCE_DOCS, NightRange, PETS_COUNT_PARAM, PETS_PARAM, PaymentLine, PaymentScheduleView, PolicyBranch, PriceQuote, QuoteAddonService, QuoteCancellationPolicy, QuoteInsurance, QuoteInsuranceOption, QuoteLine, QuoteOccupancy, QuotePolicyRule, QuoteResult, QuoteSelectionResult, QuoteUnavailableCode, QuoteView, QuoteVoucher, REGION_PARAM, SORT_OPTIONS, SORT_PARAM, STAGES, SdkConfig, SearchImage, SearchPayload, SearchResultItem, SnapshotImage, SortOption, SortValue, Stage, type Stay, Store, type StructureViolation, Unsubscribe, VALIDATED_FIELDS, VOFFICE_ENDPOINTS, type ValidationMessages, WebsiteSdk, WritableStore, activeFiltersFromQuery, activeRegions, activeSort, adaptGuestQuote, addQuoteService, assertCheckoutStructure, clearQuoteServices, createCheckoutFlow, createSdk, createStore, describeSdkError, detectCountry, emailError, fetchEndAvailability, fetchInitialAvailability, fetchQuote, fieldError, freeCancellationUntil, fullRefundRule, insuranceDocsPending, insuranceIdToReapply, insuranceProductKey, isBooleanFilterActive, isPricedQuery, isRateConfigurationFault, isSortAvailable, isSubmittableOption, isoDate, logSdkFailure, nightlyPrice, normalizePhone, occupancyOf, parseGermanParam, parseNightRange, parseParam, payWithOption, petsCountInQuery, phoneError, policyFreeCancellationUntil, postalcodeError, pricePolicyBranches, removeQuoteService, resolveSdkSort, runLiveSearch, saveBookingSnapshot, saveCheckoutContext, scopeToRegions, sdkConfigFor, selectQuoteInsurance, selectQuotePolicy, selectableInsuranceOptions, selectedInsuranceOption, shownInsuranceResources, stabilizeCardOrder, stayKeyOf, stayParams, streetWarning, submitBooking, tidyPrice };