@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 +3 -4
- package/dist/enhance/index.d.mts +4 -1
- package/dist/enhance/index.mjs +6 -2
- package/dist/index.d.mts +439 -11
- package/dist/index.mjs +676 -14
- package/package.json +3 -4
- package/dist/manifest/index.d.mts +0 -122
- package/dist/manifest/index.mjs +0 -256
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
|
|
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:
|
|
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
|
|
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
|
|
1275
|
-
*
|
|
1276
|
-
* looked up because the ISO→dial
|
|
1277
|
-
* register (its localized "Land"
|
|
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,598 @@ function assertCheckoutStructure(root) {
|
|
|
1420
1488
|
return violations;
|
|
1421
1489
|
}
|
|
1422
1490
|
//#endregion
|
|
1423
|
-
|
|
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
|
+
function mapBase(item) {
|
|
1893
|
+
const name = item.nameOrLabel.trim();
|
|
1894
|
+
const highlights = item.highlights ?? [];
|
|
1895
|
+
return {
|
|
1896
|
+
id: item.id,
|
|
1897
|
+
name,
|
|
1898
|
+
location: item.address?.city?.trim() ?? "",
|
|
1899
|
+
href: `/unterkuenfte/${slugify(name)}`,
|
|
1900
|
+
images: (item.images ?? []).map((img) => ({
|
|
1901
|
+
src: img.src,
|
|
1902
|
+
srcset: img.srcset?.trim() || void 0,
|
|
1903
|
+
alt: img.alt?.trim() || name
|
|
1904
|
+
})),
|
|
1905
|
+
guests: firstInt(highlights, /^(\d+)\s+Maximale Personenzahl$/),
|
|
1906
|
+
bedrooms: firstInt(highlights, /^(\d+)\s+Schlafzimmer$/),
|
|
1907
|
+
area: firstInt(highlights, /^(\d+)\s*m²$/),
|
|
1908
|
+
pills: [...highlights],
|
|
1909
|
+
coords: item.location ? {
|
|
1910
|
+
lat: item.location.latitude,
|
|
1911
|
+
lng: item.location.longitude
|
|
1912
|
+
} : null,
|
|
1913
|
+
propertyId: item.property?.id ?? null,
|
|
1914
|
+
isNew: false
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
/** Null-safe wrapper around the shared `tidyPrice` (drops the noise ",00" from
|
|
1918
|
+
* the SDK's pre-formatted total) for the search-result card price label. */
|
|
1919
|
+
function tidyTotal(formatted) {
|
|
1920
|
+
const s = formatted?.trim();
|
|
1921
|
+
if (!s) return null;
|
|
1922
|
+
return tidyPrice(s);
|
|
1923
|
+
}
|
|
1924
|
+
/** The offer half of a card, read from the SDK `discount` that sits beside the
|
|
1925
|
+
* total this card prints. Pass the discount from the SAME place the price came
|
|
1926
|
+
* from — the period's for an alternative card, the item's for an exact one.
|
|
1927
|
+
*
|
|
1928
|
+
* Three separate gates, because the feed states three separate facts and can
|
|
1929
|
+
* state any one without the others: the badge shows on the discount EXISTING,
|
|
1930
|
+
* the struck-through total needs `formattedOriginalTotal`, and naming the offer
|
|
1931
|
+
* needs a non-empty `applied`. A discount whose `applied` is empty is a real
|
|
1932
|
+
* discount we cannot name — it keeps its badge and its "was" price and simply
|
|
1933
|
+
* says nothing more. Labels are the PMS's words, trimmed and passed through;
|
|
1934
|
+
* an entry with no label is dropped rather than given one. */
|
|
1935
|
+
function mapDiscount(discount) {
|
|
1936
|
+
return {
|
|
1937
|
+
isOffer: Boolean(discount),
|
|
1938
|
+
offerOriginalPriceLabel: tidyTotal(discount?.formattedOriginalTotal),
|
|
1939
|
+
offers: (discount?.applied ?? []).flatMap((applied) => {
|
|
1940
|
+
const label = applied.label?.trim();
|
|
1941
|
+
if (!label) return [];
|
|
1942
|
+
return [{
|
|
1943
|
+
label,
|
|
1944
|
+
amount: tidyTotal(applied.formattedAmount)
|
|
1945
|
+
}];
|
|
1946
|
+
})
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
function mapItem(item, params, locale) {
|
|
1950
|
+
const scopeType = typeof item.scope === "string" ? item.scope : item.scope?.type;
|
|
1951
|
+
const alternativePeriodLabel = formatPeriodLabel(item.alternativePeriod ?? (typeof item.scope === "object" ? item.scope.alternativePeriod : void 0), locale);
|
|
1952
|
+
const isAlternativePeriod = alternativePeriodLabel != null || scopeType === "alternativePeriod" || scopeType === "alternative-period";
|
|
1953
|
+
const base = mapBase(item);
|
|
1954
|
+
return {
|
|
1955
|
+
...base,
|
|
1956
|
+
href: `${base.href}${buildHandover(params)}`,
|
|
1957
|
+
priceLabel: tidyTotal(item.formattedTotal),
|
|
1958
|
+
...mapDiscount(item.discount),
|
|
1959
|
+
alternativePeriodLabel,
|
|
1960
|
+
isAlternativePeriod,
|
|
1961
|
+
alternativePeriods: [],
|
|
1962
|
+
nights: null
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
/** Map a root-level alternative-period entry into the same card shape. The
|
|
1966
|
+
* rental fields come from `entry.item`; the price and the "Frei im Zeitraum …"
|
|
1967
|
+
* label come from the first candidate period (the SDK returns them best-first,
|
|
1968
|
+
* `boost`-ordered). Always flagged as an alternative so the card shows the
|
|
1969
|
+
* period pill instead of treating it as an exact total. */
|
|
1970
|
+
function mapAlternativeEntry(entry, params, locale) {
|
|
1971
|
+
const base = mapBase(entry.item);
|
|
1972
|
+
const periods = (entry.alternativePeriods ?? []).flatMap((candidate) => {
|
|
1973
|
+
const label = formatPeriodLabel({
|
|
1974
|
+
start: candidate.start,
|
|
1975
|
+
end: candidate.end
|
|
1976
|
+
}, locale);
|
|
1977
|
+
if (!label) return [];
|
|
1978
|
+
return [{
|
|
1979
|
+
label,
|
|
1980
|
+
href: `${base.href}${buildHandover(params, {
|
|
1981
|
+
start: candidate.start,
|
|
1982
|
+
end: candidate.end
|
|
1983
|
+
})}`,
|
|
1984
|
+
priceLabel: tidyTotal(candidate.formattedTotal),
|
|
1985
|
+
nights: nightsBetween(candidate.start, candidate.end),
|
|
1986
|
+
...mapDiscount(candidate.discount)
|
|
1987
|
+
}];
|
|
1988
|
+
});
|
|
1989
|
+
const period = entry.alternativePeriods?.[0];
|
|
1990
|
+
const periodPrice = tidyTotal(period?.formattedTotal);
|
|
1991
|
+
const priced = periodPrice != null ? period : entry.item;
|
|
1992
|
+
return {
|
|
1993
|
+
...base,
|
|
1994
|
+
href: `${base.href}${buildHandover(params, {
|
|
1995
|
+
start: period?.start,
|
|
1996
|
+
end: period?.end
|
|
1997
|
+
})}`,
|
|
1998
|
+
priceLabel: periodPrice ?? tidyTotal(entry.item.formattedTotal),
|
|
1999
|
+
...mapDiscount(priced?.discount),
|
|
2000
|
+
alternativePeriodLabel: formatPeriodLabel(period ? {
|
|
2001
|
+
start: period.start,
|
|
2002
|
+
end: period.end
|
|
2003
|
+
} : void 0, locale),
|
|
2004
|
+
isAlternativePeriod: true,
|
|
2005
|
+
alternativePeriods: periods,
|
|
2006
|
+
nights: nightsBetween(period?.start, period?.end)
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Run a live search for the given URL query string (e.g.
|
|
2011
|
+
* `window.location.search`). Builds the SDK query from the picker params,
|
|
2012
|
+
* calls the rate-limited live API once, and maps the items. Pass an SDK
|
|
2013
|
+
* instance to reuse its internal cache across an eager pagination walk;
|
|
2014
|
+
* otherwise this function owns a short-lived instance for a single call.
|
|
2015
|
+
*
|
|
2016
|
+
* Pass `cursor` (the previous page's `pageInfo.nextCursor`, treated as
|
|
2017
|
+
* opaque) to fetch the next page. The returned `cursor`/`hasNextPage` are
|
|
2018
|
+
* the SDK's `pageInfo` straight through, so the caller can keep paging.
|
|
2019
|
+
*/
|
|
2020
|
+
async function runLiveSearch(sdk, searchString, locale, cursor) {
|
|
2021
|
+
const params = new URLSearchParams(searchString);
|
|
2022
|
+
const nights = computeNights(params);
|
|
2023
|
+
const sort = resolveSdkSort(searchString);
|
|
2024
|
+
const query = buildQuery(params, getSupportedPeriodQueryKeys(sdk.backend));
|
|
2025
|
+
{
|
|
2026
|
+
const search = sdk.live.search.search;
|
|
2027
|
+
const result = await search({
|
|
2028
|
+
locale,
|
|
2029
|
+
query,
|
|
2030
|
+
...sort ? { sort } : {},
|
|
2031
|
+
...cursor ? { cursor } : {}
|
|
2032
|
+
});
|
|
2033
|
+
const exact = result.items.map((item) => mapItem(item, params, locale));
|
|
2034
|
+
const exactIds = new Set(exact.map((item) => item.id));
|
|
2035
|
+
const alternatives = (result.alternatives ?? []).filter((entry) => entry?.item?.id && !exactIds.has(entry.item.id)).map((entry) => mapAlternativeEntry(entry, params, locale));
|
|
2036
|
+
const items = exact.concat(alternatives);
|
|
2037
|
+
return {
|
|
2038
|
+
items,
|
|
2039
|
+
nights,
|
|
2040
|
+
alternativePeriodCount: items.filter((item) => item.isAlternativePeriod).length,
|
|
2041
|
+
cursor: result.pageInfo.nextCursor ?? null,
|
|
2042
|
+
hasNextPage: result.pageInfo.hasNextPage
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
//#endregion
|
|
2047
|
+
//#region src/regions.ts
|
|
2048
|
+
/**
|
|
2049
|
+
* The "Region" scope of the live search.
|
|
2050
|
+
*
|
|
2051
|
+
* "Region" is a v10 custom attribute (MULTI_SELECT, PUBLIC) set per rental in
|
|
2052
|
+
* v-office — e.g. Duhnen, Döse. The live search has no usable server-side key
|
|
2053
|
+
* for it: the SDK filters a custom attribute only on an exact raw match of its
|
|
2054
|
+
* stored JSON-array value, and registering the definition would fold a
|
|
2055
|
+
* `Region: ["…"]` string into every card and detail highlight (verified). So
|
|
2056
|
+
* `region` is dropped from the SDK query (search.ts, DROP_KEYS) and applied
|
|
2057
|
+
* here, client-side, against the per-rental regions the site hydrates from its
|
|
2058
|
+
* static catalogue at build time — the same channel as the other facts the
|
|
2059
|
+
* search projection does not carry.
|
|
2060
|
+
*/
|
|
2061
|
+
/** URL query key carrying the active region scope. Repeatable: several
|
|
2062
|
+
* `region=<name>` values OR together (show rentals in ANY selected region). */
|
|
2063
|
+
const REGION_PARAM = "region";
|
|
2064
|
+
/** The regions active in a URL query string: the `region` values, trimmed,
|
|
2065
|
+
* de-duplicated, order preserved. Absent or empty → `[]`. */
|
|
2066
|
+
function activeRegions(search) {
|
|
2067
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2068
|
+
for (const value of new URLSearchParams(search).getAll(REGION_PARAM)) {
|
|
2069
|
+
const region = value.trim();
|
|
2070
|
+
if (region) seen.add(region);
|
|
2071
|
+
}
|
|
2072
|
+
return [...seen];
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* The results inside the region scope. A rental may carry several regions
|
|
2076
|
+
* (MULTI_SELECT) and matches when any of them is selected; with no active
|
|
2077
|
+
* region every result stays. A rental the map does not know is outside every
|
|
2078
|
+
* region, so it drops out of a scoped search rather than passing unseen.
|
|
2079
|
+
*/
|
|
2080
|
+
function scopeToRegions(items, active, regionsByRentalId) {
|
|
2081
|
+
if (active.length === 0) return [...items];
|
|
2082
|
+
return items.filter((item) => regionsByRentalId(item.id)?.some((r) => active.includes(r)));
|
|
2083
|
+
}
|
|
2084
|
+
//#endregion
|
|
2085
|
+
export { BOOKING_SNAPSHOT_KEY, BUTTON_SOLUTION_PATTERN, CHECKOUT_CONTEXT_KEY, CHECKOUT_PARTS, CHECKOUT_RETURN_PATH, CHECKOUT_STRUCTURE, 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 };
|