@v-office/website-headless 0.0.3 → 0.1.1

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/dist/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { createWebsiteSDK } from "@v-office/website-sdk";
2
+ import { getSupportedPeriodQueryKeys } from "@v-office/website-sdk/capabilities/search";
1
3
  //#region src/store/index.ts
2
4
  function createStore(initial) {
3
5
  let state = initial;
@@ -21,6 +23,71 @@ function createStore(initial) {
21
23
  //#endregion
22
24
  //#region src/sdk.ts
23
25
  /**
26
+ * The SDK seam every module in this package shares.
27
+ *
28
+ * The package NEVER constructs a WebsiteSDK itself — construction needs the
29
+ * site's credentials and backend choice, which live in the site's own
30
+ * `src/lib/voffice.ts` (one construction point per site). Every function here
31
+ * takes the instance (or a `getSdk` thunk) from the caller instead. That is the
32
+ * package's one hard rule: behavior lives here, wiring lives in the site.
33
+ */
34
+ /** Deployment stage: selects the public endpoint set below. */
35
+ const STAGES = ["development", "production"];
36
+ /** Public endpoints of the v10 backend per stage. */
37
+ const VOFFICE_ENDPOINTS = {
38
+ development: {
39
+ apiEndpoint: "https://api.development.v-office.io",
40
+ imageBaseUrl: "https://images.development.v-office.io",
41
+ searchEndpoint: "https://search.development.v-office.io/search"
42
+ },
43
+ production: {
44
+ apiEndpoint: "https://api.production.v-office.io",
45
+ imageBaseUrl: "https://images.production.v-office.io",
46
+ searchEndpoint: "https://search.production.v-office.io/search"
47
+ }
48
+ };
49
+ /** Public endpoints of the v9 backend per stage; API URLs derive from `apiBaseUrl`. */
50
+ const HUB_ENDPOINTS = {
51
+ development: {
52
+ apiBaseUrl: "https://api.voffice.demo-on.de",
53
+ imageProxyBaseUrl: "https://imgproxy.voffice.demo-on.de"
54
+ },
55
+ production: {
56
+ apiBaseUrl: "https://api.voffice.be-on.io",
57
+ imageProxyBaseUrl: "https://imgproxy.voffice.be-on.io"
58
+ }
59
+ };
60
+ /** The three values a site actually decides — backend, stage, its token — as
61
+ * a full `SdkConfig`, with the endpoints the platform owns. */
62
+ function sdkConfigFor({ backend, stage, token }) {
63
+ if (backend === "v9") {
64
+ const { apiBaseUrl, imageProxyBaseUrl } = HUB_ENDPOINTS[stage];
65
+ return {
66
+ backend,
67
+ imageProxyBaseUrl,
68
+ v0ApiBaseUrl: `${apiBaseUrl}/api/v0`,
69
+ v1ApiBaseUrl: `${apiBaseUrl}/api/v1`,
70
+ graphqlUrl: `${apiBaseUrl}/graphql`,
71
+ apiKey: token
72
+ };
73
+ }
74
+ return {
75
+ backend,
76
+ ...VOFFICE_ENDPOINTS[stage],
77
+ accessToken: token
78
+ };
79
+ }
80
+ /** Build an SDK instance from a config (+ the SDK's options: translation
81
+ * overrides, custom attributes, highlight prioritization). The caller owns the
82
+ * lifecycle and calls `await sdk.dispose()` when done. The API is rate-limited
83
+ * (1/s), so a browser surface keeps ONE instance for its lifetime. */
84
+ function createSdk(config, options) {
85
+ return createWebsiteSDK(options ? {
86
+ config,
87
+ options
88
+ } : { config });
89
+ }
90
+ /**
24
91
  * Render `error` and its whole cause chain as indented JSON.
25
92
  *
26
93
  * Walks the chain because a cause nests (a `CoreSDKError` wrapping a thrown
@@ -224,9 +291,9 @@ function adaptGuestQuote(quote) {
224
291
  /** Price a stay for `rentalId`. The caller owns the SDK instance and its
225
292
  * lifecycle — pass a shared one to reuse its queue + cache across the caller's
226
293
  * lifetime. `voucher` re-prices with a code (the PMS decides what it's worth). */
227
- async function fetchQuote(rentalId, period, occupancy, sdk, voucher) {
294
+ async function fetchQuote(rentalId, period, occupancy, sdk, locale, voucher) {
228
295
  const result = await sdk.live.quote.quote({
229
- locale: "de-DE",
296
+ locale,
230
297
  rentalId,
231
298
  period,
232
299
  occupancy: {
@@ -546,7 +613,7 @@ const CHECKOUT_RETURN_PATH = "/buchen/bestaetigung";
546
613
  * real. Throws on transport/backend errors (the caller renders an honest
547
614
  * retry message; nothing was necessarily booked when it throws).
548
615
  */
549
- async function submitBooking(quote, contact, sdk) {
616
+ async function submitBooking(quote, contact, sdk, returnPath = CHECKOUT_RETURN_PATH) {
550
617
  const out = await sdk.live.booking.book({
551
618
  quote,
552
619
  contact: {
@@ -566,7 +633,7 @@ async function submitBooking(quote, contact, sdk) {
566
633
  } : { email: contact.email },
567
634
  ...contact.note ? { note: contact.note } : {}
568
635
  },
569
- relativeRedirectUrl: CHECKOUT_RETURN_PATH
636
+ relativeRedirectUrl: returnPath
570
637
  }).catch((error) => {
571
638
  logSdkFailure("booking.book", error);
572
639
  throw error;
@@ -884,7 +951,7 @@ function createCheckoutFlow(initialOptions) {
884
951
  const result = await fetchQuote(rentalId, {
885
952
  start: isoDate(targetStay.start),
886
953
  end: isoDate(targetStay.end)
887
- }, occupancyOf(targetStay), getSdk(), voucher ?? void 0);
954
+ }, occupancyOf(targetStay), getSdk(), opts.locale, voucher ?? void 0);
888
955
  if (myGen !== gen) return;
889
956
  if (result.status !== "available") {
890
957
  const rateFault = isRateConfigurationFault(result.code);
@@ -1041,7 +1108,7 @@ function createCheckoutFlow(initialOptions) {
1041
1108
  const result = await fetchQuote(rentalId, {
1042
1109
  start: isoDate(stay.start),
1043
1110
  end: isoDate(stay.end)
1044
- }, occupancyOf(stay), getSdk(), code ?? void 0);
1111
+ }, occupancyOf(stay), getSdk(), opts.locale, code ?? void 0);
1045
1112
  if (result.status !== "available") {
1046
1113
  patch({ voucherError: isRateConfigurationFault(result.code) ? opts.messages.rateFault : result.reason || opts.messages.voucherFailed });
1047
1114
  return;
@@ -1118,7 +1185,7 @@ function createCheckoutFlow(initialOptions) {
1118
1185
  quote = res.quote;
1119
1186
  updateFromLive(quote);
1120
1187
  }
1121
- return submitBooking(quote, contact, getSdk());
1188
+ return submitBooking(quote, contact, getSdk(), opts.returnPath);
1122
1189
  };
1123
1190
  submit().then((result) => {
1124
1191
  patch({
@@ -1203,11 +1270,11 @@ function derive(raw, opts) {
1203
1270
  }
1204
1271
  //#endregion
1205
1272
  //#region src/checkout/validation.ts
1206
- function normalizePhone(raw, dial = "49") {
1273
+ function normalizePhone(raw, dial) {
1207
1274
  const v = raw.replace(/[-./()]+/g, " ").replace(/\s+/g, " ").trim();
1208
1275
  if (!v) return "";
1209
1276
  if (v.startsWith("00")) return "+" + v.slice(2);
1210
- if (v.startsWith("0")) return "+" + dial + " " + v.slice(1);
1277
+ if (v.startsWith("0") && dial) return "+" + dial + " " + v.slice(1);
1211
1278
  return v;
1212
1279
  }
1213
1280
  function phoneError(raw, dial, c) {
@@ -1271,10 +1338,11 @@ const VALIDATED_FIELDS = [
1271
1338
  "city"
1272
1339
  ];
1273
1340
  /** Validate one named field. Takes the selected COUNTRY plus its calling code
1274
- * (`dial`): two validators depend on the country — the phone via the dial
1275
- * code, the PLZ via its format rule. The dial code is passed in rather than
1276
- * looked up because the ISO→dial mapping lives with the site's country
1277
- * register (its localized "Land" options), not here. */
1341
+ * (`dial`, undefined when the register has none for it): two validators depend
1342
+ * on the country — the phone via the dial code, the PLZ via its format rule.
1343
+ * The dial code is passed in rather than looked up because the ISO→dial
1344
+ * mapping lives with the site's country register (its localized "Land"
1345
+ * options), not here. */
1278
1346
  function fieldError(name, value, country, dial, c) {
1279
1347
  if (name === "email") return emailError(value, c);
1280
1348
  if (name === "phone") return phoneError(value, dial, c);
@@ -1420,4 +1488,609 @@ function assertCheckoutStructure(root) {
1420
1488
  return violations;
1421
1489
  }
1422
1490
  //#endregion
1423
- export { BOOKING_SNAPSHOT_KEY, BUTTON_SOLUTION_PATTERN, CHECKOUT_CONTEXT_KEY, CHECKOUT_PARTS, CHECKOUT_RETURN_PATH, CHECKOUT_STRUCTURE, MULTI_RATE_CODES, NO_INSURANCE_DOCS, VALIDATED_FIELDS, 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 };
1491
+ //#region src/dates.ts
1492
+ const localDate = (y, m, d) => {
1493
+ const date = new Date(y, m - 1, d);
1494
+ return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d ? date : void 0;
1495
+ };
1496
+ function parseParam(value) {
1497
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? "");
1498
+ return m ? localDate(Number(m[1]), Number(m[2]), Number(m[3])) : void 0;
1499
+ }
1500
+ function parseGermanParam(value) {
1501
+ const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(value ?? "");
1502
+ return m ? localDate(Number(m[3]), Number(m[2]), Number(m[1])) : void 0;
1503
+ }
1504
+ /** A flexible-length stay ("3–5 Nächte") as the URL carries it. Off until the
1505
+ * search UI offers it; the keys are reserved so a site can enable it. */
1506
+ const NIGHT_RANGE_ENABLED = false;
1507
+ const NIGHT_RANGE_KEYS = [
1508
+ "minNights",
1509
+ "maxNights",
1510
+ "nights_min",
1511
+ "nights_max"
1512
+ ];
1513
+ function parseNightRange(params) {
1514
+ return null;
1515
+ }
1516
+ //#endregion
1517
+ //#region src/format.ts
1518
+ /**
1519
+ * Display tidy for an SDK pre-formatted German-locale price ("1.234,00 €").
1520
+ * Nearly every stay is whole euros, so the trailing ",00" is noise — dropped only
1521
+ * when it is the zero-cents group at the end, so real cents ("1.234,50 €") stay.
1522
+ * A real value formatted for display, nothing fabricated; other formats pass.
1523
+ */
1524
+ function tidyPrice(formatted) {
1525
+ return formatted.replace(/,00(?=\D*$)/, "");
1526
+ }
1527
+ //#endregion
1528
+ //#region src/availability.ts
1529
+ /**
1530
+ * The per-night price a calendar CELL shows, rounded to whole euros:
1531
+ * "116,43 €" -> "116 €", "116,50 €" -> "117 €".
1532
+ *
1533
+ * A month grid is thirty-odd numbers seen at a glance and acted on by NONE of
1534
+ * them — the guest picks a range, and the quote under the calendar then states
1535
+ * the stay to the last cent. Two decimals in every cell buy precision nobody
1536
+ * reads, at the one size where the digits are already tight.
1537
+ *
1538
+ * ROUNDS TO NEAREST, AND THAT IS THE POINT OF THE FUNCTION. Stripping the cents
1539
+ * with a regex is one line and understates every priced night by up to 99
1540
+ * cents, always in the direction that flatters the price. That is the shape of
1541
+ * claim a host has to stand behind, so the error goes both ways here and is
1542
+ * capped at 50 cents on a figure that is explicitly indicative.
1543
+ *
1544
+ * Parses the German format rather than doing string surgery on it: "." groups
1545
+ * thousands, "," is the decimal. Anything that does not match that shape —
1546
+ * another locale, no decimal group, a price the SDK formatted differently — is
1547
+ * returned UNCHANGED. A display tidy must never turn a price it did not
1548
+ * understand into a wrong one.
1549
+ *
1550
+ * This runs where `priceByDay` is BUILT rather than where a cell renders it, so
1551
+ * every calendar fed from this map agrees: the widget's picker and the
1552
+ * checkout's copy, which reads the same shape.
1553
+ */
1554
+ function nightlyPrice(formatted, locale = "de-DE") {
1555
+ const m = formatted.match(/(\d{1,3}(?:\.\d{3})*|\d+),(\d{2})(?=\D*$)/);
1556
+ if (!m) return formatted;
1557
+ const euros = Number(m[1].replace(/\./g, ""));
1558
+ const cents = Number(m[2]);
1559
+ if (!Number.isFinite(euros) || !Number.isFinite(cents)) return formatted;
1560
+ return formatted.replace(m[0], (euros + (cents >= 50 ? 1 : 0)).toLocaleString(locale));
1561
+ }
1562
+ /** SDK day statuses that mean the rental is genuinely unbookable on that day
1563
+ * (booked / closed / past the bookable horizon) — as opposed to a day that is
1564
+ * generally available but fails a RULE for the current selection (min-stay,
1565
+ * check-out-not-allowed, …). The picker strikes the former through; the latter
1566
+ * it only dims. Spans both calls' status enums
1567
+ * (node_modules/@v-office/website-sdk/instructions/availability.md,
1568
+ * "Common statuses"). */
1569
+ const HARD_UNAVAILABLE_STATUS = /* @__PURE__ */ new Set([
1570
+ "unavailable",
1571
+ "after_last_bookable_date",
1572
+ "range_crosses_unavailable_date_and_is_unavailable"
1573
+ ]);
1574
+ /** Possible check-in days + occupancy caps for `rentalId` across the window.
1575
+ * The caller's SDK instance is reused so its 30s per-rental cache spans the
1576
+ * picker's lifetime. */
1577
+ async function fetchInitialAvailability(sdk, rentalId, calculateFromDate, calculateThroughDate, locale) {
1578
+ {
1579
+ const out = await sdk.live.availability.getInitialAvailability({
1580
+ locale,
1581
+ rentalId,
1582
+ calculateFromDate,
1583
+ calculateThroughDate
1584
+ });
1585
+ const canStart = /* @__PURE__ */ new Set();
1586
+ const hardBlocked = /* @__PURE__ */ new Set();
1587
+ const priceByDay = /* @__PURE__ */ new Map();
1588
+ const tooltipByDay = /* @__PURE__ */ new Map();
1589
+ for (const [iso, day] of Object.entries(out.calendarDays)) {
1590
+ if (day.canBeStartDate) canStart.add(iso);
1591
+ if (day.status && HARD_UNAVAILABLE_STATUS.has(day.status)) hardBlocked.add(iso);
1592
+ if (day.formattedPrice) priceByDay.set(iso, nightlyPrice(day.formattedPrice, locale));
1593
+ if (day.tooltip) tooltipByDay.set(iso, day.tooltip);
1594
+ }
1595
+ return {
1596
+ canStart,
1597
+ hardBlocked,
1598
+ priceByDay,
1599
+ tooltipByDay,
1600
+ maxPersons: out.maxPersons ?? null,
1601
+ maxAdults: out.maxAdults ?? null
1602
+ };
1603
+ }
1604
+ }
1605
+ /** Possible check-out days for a chosen `selectedStartDate`. */
1606
+ async function fetchEndAvailability(sdk, rentalId, calculateFromDate, calculateThroughDate, selectedStartDate, locale) {
1607
+ {
1608
+ const out = await sdk.live.availability.getStartDateSelectedAvailability({
1609
+ locale,
1610
+ rentalId,
1611
+ calculateFromDate,
1612
+ calculateThroughDate,
1613
+ selectedStartDate
1614
+ });
1615
+ const canEnd = /* @__PURE__ */ new Set();
1616
+ const hardBlocked = /* @__PURE__ */ new Set();
1617
+ const tooltipByDay = /* @__PURE__ */ new Map();
1618
+ for (const [iso, day] of Object.entries(out.calendarDays)) {
1619
+ if (day.canBeEndDate) canEnd.add(iso);
1620
+ if (day.status && HARD_UNAVAILABLE_STATUS.has(day.status)) hardBlocked.add(iso);
1621
+ if (day.tooltip) tooltipByDay.set(iso, day.tooltip);
1622
+ }
1623
+ return {
1624
+ canEnd,
1625
+ hardBlocked,
1626
+ tooltipByDay
1627
+ };
1628
+ }
1629
+ }
1630
+ //#endregion
1631
+ //#region src/filters.ts
1632
+ /** A boolean filter is active when its param is present and truthy. */
1633
+ const isBooleanFilterActive = (value) => value !== "" && value !== "false" && value !== "0";
1634
+ /**
1635
+ * The filters currently applied in the query, in the order the defs are
1636
+ * declared — the removable chips. An `int` label is prefixed with the localized
1637
+ * "minimum" wording, because its value is a lower bound.
1638
+ */
1639
+ function activeFiltersFromQuery(searchParams, defs, minimumPrefix) {
1640
+ const active = [];
1641
+ for (const def of defs) {
1642
+ const value = searchParams.get(def.key);
1643
+ if (value === null) continue;
1644
+ if (def.type === "boolean") {
1645
+ if (isBooleanFilterActive(value)) active.push({
1646
+ key: def.key,
1647
+ label: def.label
1648
+ });
1649
+ continue;
1650
+ }
1651
+ const count = Number.parseInt(value, 10);
1652
+ if (Number.isFinite(count) && count > 0) active.push({
1653
+ key: def.key,
1654
+ label: `${minimumPrefix} ${count} ${def.label}`
1655
+ });
1656
+ }
1657
+ return active;
1658
+ }
1659
+ /** The dog-friendly search as the URL carries it: `pets=true` (or a count) and,
1660
+ * from an older vocabulary, `petsCount=N`. Read by the live search. */
1661
+ const PETS_PARAM = "pets";
1662
+ const PETS_COUNT_PARAM = "petsCount";
1663
+ function petsCountInQuery(search) {
1664
+ const params = new URLSearchParams(search);
1665
+ const count = Number.parseInt(params.get("petsCount") ?? "", 10);
1666
+ if (Number.isFinite(count) && count > 0) return count;
1667
+ const flag = params.get(PETS_PARAM);
1668
+ if (flag === "true") return 1;
1669
+ const n = Number.parseInt(flag ?? "", 10);
1670
+ return Number.isFinite(n) && n > 0 ? n : 0;
1671
+ }
1672
+ //#endregion
1673
+ //#region src/sort.ts
1674
+ /** URL param. A client-side scope like `property`/`region`, NOT an SDK query
1675
+ * key — it is dropped from the SDK query string in search.ts's DROP_KEYS and
1676
+ * travels as the separate `sort` input instead. */
1677
+ const SORT_PARAM = "sort";
1678
+ /** The backend's own order. Emits no `sort` at all, so it is exactly the
1679
+ * ordering the page has always shown. Typed as the literal (not `SortValue`)
1680
+ * so `value === DEFAULT_SORT` narrows the rest away for `SDK_SORT`. */
1681
+ const DEFAULT_SORT = "empfohlen";
1682
+ const SORT_OPTIONS = [
1683
+ {
1684
+ value: "empfohlen",
1685
+ label: "Empfohlen"
1686
+ },
1687
+ {
1688
+ value: "preis-auf",
1689
+ label: "Preis aufsteigend",
1690
+ pricedOnly: true
1691
+ },
1692
+ {
1693
+ value: "preis-ab",
1694
+ label: "Preis absteigend",
1695
+ pricedOnly: true
1696
+ }
1697
+ ];
1698
+ /** Value → the SDK's discriminated `sort` input. `empfohlen` is absent by
1699
+ * design: no key means no `sort` sent. */
1700
+ const SDK_SORT = {
1701
+ "preis-auf": {
1702
+ by: "price",
1703
+ direction: "ASC"
1704
+ },
1705
+ "preis-ab": {
1706
+ by: "price",
1707
+ direction: "DESC"
1708
+ }
1709
+ };
1710
+ function isSortValue(value) {
1711
+ return value != null && SORT_OPTIONS.some((o) => o.value === value);
1712
+ }
1713
+ /** The sort in the URL, or the default when absent/unknown. */
1714
+ function activeSort(search) {
1715
+ const raw = new URLSearchParams(search).get(SORT_PARAM);
1716
+ return isSortValue(raw) ? raw : DEFAULT_SORT;
1717
+ }
1718
+ /**
1719
+ * Can this query be priced? Mirrors the SDK's own precondition: a fixed period
1720
+ * (`start`+`end`) or a flexible window (`dates`/`month`/`weekend`), AND
1721
+ * occupancy. Without both, the backend returns no totals and the SDK rejects
1722
+ * price ordering before it reaches the wire.
1723
+ *
1724
+ * Period keys count only with a value: the search's `buildQuery` drops empty
1725
+ * params (`?start=&end=`), so a key that is merely present would pass this
1726
+ * gate and still reach the SDK undated. `weekend` is the exception — it is a
1727
+ * presence-only flag on both sides.
1728
+ */
1729
+ function isPricedQuery(search) {
1730
+ const params = new URLSearchParams(search);
1731
+ const hasValue = (key) => (params.get(key) ?? "").trim() !== "";
1732
+ const period = hasValue("start") && hasValue("end") || hasValue("dates") || hasValue("month") || params.has("weekend");
1733
+ const adults = Number.parseInt(params.get("adults") ?? "", 10);
1734
+ return period && Number.isFinite(adults) && adults > 0;
1735
+ }
1736
+ /** Is this option selectable for the current query? */
1737
+ function isSortAvailable(option, search) {
1738
+ return !option.pricedOnly || isPricedQuery(search);
1739
+ }
1740
+ /**
1741
+ * The `sort` to hand the SDK for this URL, or `undefined` for the backend's
1742
+ * own order. Falls back to `undefined` (rather than throwing) when a price
1743
+ * sort survives in the URL after the dates were cleared.
1744
+ */
1745
+ function resolveSdkSort(search) {
1746
+ const value = activeSort(search);
1747
+ if (value === "empfohlen") return void 0;
1748
+ const option = SORT_OPTIONS.find((o) => o.value === value);
1749
+ if (option && !isSortAvailable(option, search)) return void 0;
1750
+ return SDK_SORT[value];
1751
+ }
1752
+ //#endregion
1753
+ //#region src/search.ts
1754
+ const DROP_KEYS = /* @__PURE__ */ new Set([
1755
+ "unterkunft",
1756
+ "flex",
1757
+ "property",
1758
+ "region",
1759
+ "sort"
1760
+ ]);
1761
+ const ALL_PERIOD_QUERY_KEYS = /* @__PURE__ */ new Set([...getSupportedPeriodQueryKeys("v9"), ...getSupportedPeriodQueryKeys("v10")]);
1762
+ const NIGHT_RANGE_KEY_SET = new Set(NIGHT_RANGE_KEYS);
1763
+ /** URL slug: German-transliterated, lowercased, hyphenated, capped. Mirrors
1764
+ * the slug base assigned in src/lib/rentals.ts so a result links to its
1765
+ * detail page. URL generation, not rental content. */
1766
+ function slugify(input) {
1767
+ return input.trim().toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
1768
+ }
1769
+ /** First integer captured by `re` across the SDK highlights ("78 m²" → 78).
1770
+ * Reads the number the SDK already states; it does not invent one. */
1771
+ function firstInt(items, re) {
1772
+ for (const s of items) {
1773
+ const m = re.exec(s);
1774
+ if (m) return Number.parseInt(m[1], 10);
1775
+ }
1776
+ return 0;
1777
+ }
1778
+ /** Parse an SDK/picker date string to a Date, or null. Accepts both formats the
1779
+ * feed and pickers emit: ISO `YYYY-MM-DD` (start/end params, SDK periods) and
1780
+ * German `DD-MM-YYYY` (the `dates=` path). Single source for both the date
1781
+ * label and the nights math so the two never diverge on format. */
1782
+ function parseSdkDate(value) {
1783
+ if (!value) return null;
1784
+ const trimmed = value.trim();
1785
+ const germanDate = /^(\d{2})-(\d{2})-(\d{4})$/.exec(trimmed);
1786
+ const date = germanDate ? new Date(Number.parseInt(germanDate[3], 10), Number.parseInt(germanDate[2], 10) - 1, Number.parseInt(germanDate[1], 10)) : new Date(trimmed);
1787
+ return Number.isNaN(date.getTime()) ? null : date;
1788
+ }
1789
+ /** Whole nights between two SDK date strings (either accepted format), or null
1790
+ * when a date is missing/unparseable or the span is non-positive. */
1791
+ function nightsBetween(start, end) {
1792
+ const from = parseSdkDate(start);
1793
+ const to = parseSdkDate(end);
1794
+ if (!from || !to) return null;
1795
+ const nights = Math.round((to.getTime() - from.getTime()) / 864e5);
1796
+ return nights > 0 ? nights : null;
1797
+ }
1798
+ function computeNights(params) {
1799
+ if (parseNightRange(params) !== null) return null;
1800
+ const exact = nightsBetween(params.get("start"), params.get("end"));
1801
+ if (exact != null) return exact;
1802
+ const flexible = Number.parseInt(params.get("nights") ?? "", 10);
1803
+ return Number.isFinite(flexible) && flexible > 0 ? flexible : null;
1804
+ }
1805
+ function formatDateLabel(value, locale) {
1806
+ const date = parseSdkDate(value);
1807
+ if (!date) return null;
1808
+ return new Intl.DateTimeFormat(locale, {
1809
+ day: "numeric",
1810
+ month: "short"
1811
+ }).format(date);
1812
+ }
1813
+ function formatPeriodLabel(value, locale) {
1814
+ if (!value) return null;
1815
+ if (typeof value === "string") return value.trim() || null;
1816
+ const label = value.label?.trim();
1817
+ if (label) return label;
1818
+ const start = formatDateLabel(value.start ?? value.from, locale);
1819
+ const end = formatDateLabel(value.end ?? value.till ?? value.to, locale);
1820
+ if (start && end) return `${start} – ${end}`;
1821
+ return start ?? end;
1822
+ }
1823
+ /** Forward the page's URL params to the SDK query string. The picker param
1824
+ * names already match the SDK's stable keys; only the UI-only fields and
1825
+ * empties are dropped. Repeated keys (e.g. several `month=` from a flexible
1826
+ * search) are preserved. Date formats pass straight through: the SDK accepts
1827
+ * both the picker's ISO `start`/`end` (`YYYY-MM-DD`) and the `DD-MM-YYYY`
1828
+ * the `dates=` path emits (verified directly against the live API — both
1829
+ * return identical results). `weekend` is a presence-only flag: the picker
1830
+ * posts `weekend=1` so the GET form keeps it, but the SDK wants the bare
1831
+ * `weekend` token (a `weekend=false`/`weekend=1` value would be rejected). */
1832
+ function buildQuery(params, supportedPeriodKeys) {
1833
+ const query = new URLSearchParams();
1834
+ let weekend = false;
1835
+ const nightRange = parseNightRange(params);
1836
+ const hasPetsCount = params.has("petsCount");
1837
+ for (const [key, value] of params) {
1838
+ if (DROP_KEYS.has(key)) continue;
1839
+ if (key === "pets") continue;
1840
+ if (ALL_PERIOD_QUERY_KEYS.has(key) && !supportedPeriodKeys.has(key)) continue;
1841
+ if (NIGHT_RANGE_KEY_SET.has(key)) continue;
1842
+ if (key === "weekend") {
1843
+ weekend = true;
1844
+ continue;
1845
+ }
1846
+ if (value === "") continue;
1847
+ query.append(key, value);
1848
+ }
1849
+ if (!hasPetsCount) {
1850
+ const legacyPets = petsCountInQuery(params.toString());
1851
+ if (legacyPets > 0) query.set("petsCount", String(legacyPets));
1852
+ }
1853
+ if (nightRange && !weekend && query.has("start") && query.has("end") && !query.has("month") && !query.has("dates") && !query.has("nights") && supportedPeriodKeys.has("minNights")) {
1854
+ query.set("minNights", String(nightRange.min));
1855
+ query.set("maxNights", String(nightRange.max));
1856
+ }
1857
+ const base = query.toString();
1858
+ if (!weekend) return base;
1859
+ return base ? `${base}&weekend` : "weekend";
1860
+ }
1861
+ const HANDOVER_KEYS = [
1862
+ "start",
1863
+ "end",
1864
+ "adults",
1865
+ "children",
1866
+ "childrenAges",
1867
+ "babies",
1868
+ "pets"
1869
+ ];
1870
+ /** Build the `?…` suffix appended to a result card's detail link so the search
1871
+ * context survives the click. Forwards only the booking-context keys
1872
+ * (`HANDOVER_KEYS`), preserving repeated values (e.g. several `childrenAges`).
1873
+ * Pass `dates` to override `start`/`end` with an alternative-period card's
1874
+ * concrete suggested window — its bookable dates, not the user's original
1875
+ * query (which had none for a flexible search). Returns "" when nothing
1876
+ * applies, so the link stays clean. */
1877
+ function buildHandover(params, dates) {
1878
+ const out = new URLSearchParams();
1879
+ const windowOnly = dates == null && parseNightRange(params) !== null;
1880
+ for (const key of HANDOVER_KEYS) {
1881
+ if ((dates || windowOnly) && (key === "start" || key === "end")) continue;
1882
+ if (key === "pets") continue;
1883
+ for (const value of params.getAll(key)) if (value !== "") out.append(key, value);
1884
+ }
1885
+ const pets = petsCountInQuery(params.toString());
1886
+ if (pets > 0) out.set("pets", String(pets));
1887
+ if (dates?.start) out.set("start", dates.start);
1888
+ if (dates?.end) out.set("end", dates.end);
1889
+ const suffix = out.toString();
1890
+ return suffix ? `?${suffix}` : "";
1891
+ }
1892
+ /** The rental detail pages' path when the caller names none — the site
1893
+ * starter's German route. A site whose pages live elsewhere (a locale prefix,
1894
+ * another segment) passes its own `rentalsPath` to `runLiveSearch`. */
1895
+ const DEFAULT_RENTALS_PATH = "/unterkuenfte";
1896
+ function mapBase(item, rentalsPath) {
1897
+ const name = item.nameOrLabel.trim();
1898
+ const highlights = item.highlights ?? [];
1899
+ return {
1900
+ id: item.id,
1901
+ name,
1902
+ location: item.address?.city?.trim() ?? "",
1903
+ href: `${rentalsPath.replace(/\/+$/, "")}/${slugify(name)}`,
1904
+ images: (item.images ?? []).map((img) => ({
1905
+ src: img.src,
1906
+ srcset: img.srcset?.trim() || void 0,
1907
+ alt: img.alt?.trim() || name
1908
+ })),
1909
+ guests: firstInt(highlights, /^(\d+)\s+Maximale Personenzahl$/),
1910
+ bedrooms: firstInt(highlights, /^(\d+)\s+Schlafzimmer$/),
1911
+ area: firstInt(highlights, /^(\d+)\s*m²$/),
1912
+ pills: [...highlights],
1913
+ coords: item.location ? {
1914
+ lat: item.location.latitude,
1915
+ lng: item.location.longitude
1916
+ } : null,
1917
+ propertyId: item.property?.id ?? null,
1918
+ isNew: false
1919
+ };
1920
+ }
1921
+ /** Null-safe wrapper around the shared `tidyPrice` (drops the noise ",00" from
1922
+ * the SDK's pre-formatted total) for the search-result card price label. */
1923
+ function tidyTotal(formatted) {
1924
+ const s = formatted?.trim();
1925
+ if (!s) return null;
1926
+ return tidyPrice(s);
1927
+ }
1928
+ /** The offer half of a card, read from the SDK `discount` that sits beside the
1929
+ * total this card prints. Pass the discount from the SAME place the price came
1930
+ * from — the period's for an alternative card, the item's for an exact one.
1931
+ *
1932
+ * Three separate gates, because the feed states three separate facts and can
1933
+ * state any one without the others: the badge shows on the discount EXISTING,
1934
+ * the struck-through total needs `formattedOriginalTotal`, and naming the offer
1935
+ * needs a non-empty `applied`. A discount whose `applied` is empty is a real
1936
+ * discount we cannot name — it keeps its badge and its "was" price and simply
1937
+ * says nothing more. Labels are the PMS's words, trimmed and passed through;
1938
+ * an entry with no label is dropped rather than given one. */
1939
+ function mapDiscount(discount) {
1940
+ return {
1941
+ isOffer: Boolean(discount),
1942
+ offerOriginalPriceLabel: tidyTotal(discount?.formattedOriginalTotal),
1943
+ offers: (discount?.applied ?? []).flatMap((applied) => {
1944
+ const label = applied.label?.trim();
1945
+ if (!label) return [];
1946
+ return [{
1947
+ label,
1948
+ amount: tidyTotal(applied.formattedAmount)
1949
+ }];
1950
+ })
1951
+ };
1952
+ }
1953
+ function mapItem(item, params, locale, rentalsPath) {
1954
+ const scopeType = typeof item.scope === "string" ? item.scope : item.scope?.type;
1955
+ const alternativePeriodLabel = formatPeriodLabel(item.alternativePeriod ?? (typeof item.scope === "object" ? item.scope.alternativePeriod : void 0), locale);
1956
+ const isAlternativePeriod = alternativePeriodLabel != null || scopeType === "alternativePeriod" || scopeType === "alternative-period";
1957
+ const base = mapBase(item, rentalsPath);
1958
+ return {
1959
+ ...base,
1960
+ href: `${base.href}${buildHandover(params)}`,
1961
+ priceLabel: tidyTotal(item.formattedTotal),
1962
+ ...mapDiscount(item.discount),
1963
+ alternativePeriodLabel,
1964
+ isAlternativePeriod,
1965
+ alternativePeriods: [],
1966
+ nights: null
1967
+ };
1968
+ }
1969
+ /** Map a root-level alternative-period entry into the same card shape. The
1970
+ * rental fields come from `entry.item`; the price and the "Frei im Zeitraum …"
1971
+ * label come from the first candidate period (the SDK returns them best-first,
1972
+ * `boost`-ordered). Always flagged as an alternative so the card shows the
1973
+ * period pill instead of treating it as an exact total. */
1974
+ function mapAlternativeEntry(entry, params, locale, rentalsPath) {
1975
+ const base = mapBase(entry.item, rentalsPath);
1976
+ const periods = (entry.alternativePeriods ?? []).flatMap((candidate) => {
1977
+ const label = formatPeriodLabel({
1978
+ start: candidate.start,
1979
+ end: candidate.end
1980
+ }, locale);
1981
+ if (!label) return [];
1982
+ return [{
1983
+ label,
1984
+ href: `${base.href}${buildHandover(params, {
1985
+ start: candidate.start,
1986
+ end: candidate.end
1987
+ })}`,
1988
+ priceLabel: tidyTotal(candidate.formattedTotal),
1989
+ nights: nightsBetween(candidate.start, candidate.end),
1990
+ ...mapDiscount(candidate.discount)
1991
+ }];
1992
+ });
1993
+ const period = entry.alternativePeriods?.[0];
1994
+ const periodPrice = tidyTotal(period?.formattedTotal);
1995
+ const priced = periodPrice != null ? period : entry.item;
1996
+ return {
1997
+ ...base,
1998
+ href: `${base.href}${buildHandover(params, {
1999
+ start: period?.start,
2000
+ end: period?.end
2001
+ })}`,
2002
+ priceLabel: periodPrice ?? tidyTotal(entry.item.formattedTotal),
2003
+ ...mapDiscount(priced?.discount),
2004
+ alternativePeriodLabel: formatPeriodLabel(period ? {
2005
+ start: period.start,
2006
+ end: period.end
2007
+ } : void 0, locale),
2008
+ isAlternativePeriod: true,
2009
+ alternativePeriods: periods,
2010
+ nights: nightsBetween(period?.start, period?.end)
2011
+ };
2012
+ }
2013
+ /**
2014
+ * Run a live search for the given URL query string (e.g.
2015
+ * `window.location.search`). Builds the SDK query from the picker params,
2016
+ * calls the rate-limited live API once, and maps the items. Pass an SDK
2017
+ * instance to reuse its internal cache across an eager pagination walk;
2018
+ * otherwise this function owns a short-lived instance for a single call.
2019
+ *
2020
+ * Pass `cursor` (the previous page's `pageInfo.nextCursor`, treated as
2021
+ * opaque) to fetch the next page. The returned `cursor`/`hasNextPage` are
2022
+ * the SDK's `pageInfo` straight through, so the caller can keep paging.
2023
+ *
2024
+ * `options.rentalsPath` is the path the site serves its rental detail pages
2025
+ * under — the part of every result `href` before the slug. Routing is the
2026
+ * site's decision (a locale prefix like `/de/unterkuenfte`, another segment),
2027
+ * so it arrives as an option the way `SearchBar.action` and `Quote.checkoutPath`
2028
+ * do; `locale` never implies it. Defaults to `DEFAULT_RENTALS_PATH`.
2029
+ */
2030
+ async function runLiveSearch(sdk, searchString, locale, cursor, options = {}) {
2031
+ const rentalsPath = options.rentalsPath ?? "/unterkuenfte";
2032
+ const params = new URLSearchParams(searchString);
2033
+ const nights = computeNights(params);
2034
+ const sort = resolveSdkSort(searchString);
2035
+ const query = buildQuery(params, getSupportedPeriodQueryKeys(sdk.backend));
2036
+ {
2037
+ const search = sdk.live.search.search;
2038
+ const result = await search({
2039
+ locale,
2040
+ query,
2041
+ ...sort ? { sort } : {},
2042
+ ...cursor ? { cursor } : {}
2043
+ });
2044
+ const exact = result.items.map((item) => mapItem(item, params, locale, rentalsPath));
2045
+ const exactIds = new Set(exact.map((item) => item.id));
2046
+ const alternatives = (result.alternatives ?? []).filter((entry) => entry?.item?.id && !exactIds.has(entry.item.id)).map((entry) => mapAlternativeEntry(entry, params, locale, rentalsPath));
2047
+ const items = exact.concat(alternatives);
2048
+ return {
2049
+ items,
2050
+ nights,
2051
+ alternativePeriodCount: items.filter((item) => item.isAlternativePeriod).length,
2052
+ cursor: result.pageInfo.nextCursor ?? null,
2053
+ hasNextPage: result.pageInfo.hasNextPage
2054
+ };
2055
+ }
2056
+ }
2057
+ //#endregion
2058
+ //#region src/regions.ts
2059
+ /**
2060
+ * The "Region" scope of the live search.
2061
+ *
2062
+ * "Region" is a v10 custom attribute (MULTI_SELECT, PUBLIC) set per rental in
2063
+ * v-office — e.g. Duhnen, Döse. The live search has no usable server-side key
2064
+ * for it: the SDK filters a custom attribute only on an exact raw match of its
2065
+ * stored JSON-array value, and registering the definition would fold a
2066
+ * `Region: ["…"]` string into every card and detail highlight (verified). So
2067
+ * `region` is dropped from the SDK query (search.ts, DROP_KEYS) and applied
2068
+ * here, client-side, against the per-rental regions the site hydrates from its
2069
+ * static catalogue at build time — the same channel as the other facts the
2070
+ * search projection does not carry.
2071
+ */
2072
+ /** URL query key carrying the active region scope. Repeatable: several
2073
+ * `region=<name>` values OR together (show rentals in ANY selected region). */
2074
+ const REGION_PARAM = "region";
2075
+ /** The regions active in a URL query string: the `region` values, trimmed,
2076
+ * de-duplicated, order preserved. Absent or empty → `[]`. */
2077
+ function activeRegions(search) {
2078
+ const seen = /* @__PURE__ */ new Set();
2079
+ for (const value of new URLSearchParams(search).getAll(REGION_PARAM)) {
2080
+ const region = value.trim();
2081
+ if (region) seen.add(region);
2082
+ }
2083
+ return [...seen];
2084
+ }
2085
+ /**
2086
+ * The results inside the region scope. A rental may carry several regions
2087
+ * (MULTI_SELECT) and matches when any of them is selected; with no active
2088
+ * region every result stays. A rental the map does not know is outside every
2089
+ * region, so it drops out of a scoped search rather than passing unseen.
2090
+ */
2091
+ function scopeToRegions(items, active, regionsByRentalId) {
2092
+ if (active.length === 0) return [...items];
2093
+ return items.filter((item) => regionsByRentalId(item.id)?.some((r) => active.includes(r)));
2094
+ }
2095
+ //#endregion
2096
+ export { BOOKING_SNAPSHOT_KEY, BUTTON_SOLUTION_PATTERN, CHECKOUT_CONTEXT_KEY, CHECKOUT_PARTS, CHECKOUT_RETURN_PATH, CHECKOUT_STRUCTURE, DEFAULT_RENTALS_PATH, DEFAULT_SORT, HUB_ENDPOINTS, MULTI_RATE_CODES, NIGHT_RANGE_ENABLED, NIGHT_RANGE_KEYS, NO_INSURANCE_DOCS, PETS_COUNT_PARAM, PETS_PARAM, REGION_PARAM, SORT_OPTIONS, SORT_PARAM, STAGES, VALIDATED_FIELDS, VOFFICE_ENDPOINTS, 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 };