@v-office/website-headless 0.0.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/README.md +86 -0
- package/dist/enhance/index.d.mts +181 -0
- package/dist/enhance/index.mjs +533 -0
- package/dist/index.d.mts +912 -0
- package/dist/index.mjs +1423 -0
- package/dist/manifest/index.d.mts +118 -0
- package/dist/manifest/index.mjs +264 -0
- package/package.json +48 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1423 @@
|
|
|
1
|
+
//#region src/store/index.ts
|
|
2
|
+
function createStore(initial) {
|
|
3
|
+
let state = initial;
|
|
4
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
5
|
+
return {
|
|
6
|
+
getState: () => state,
|
|
7
|
+
setState(next) {
|
|
8
|
+
if (next === state) return;
|
|
9
|
+
state = next;
|
|
10
|
+
const current = [...listeners];
|
|
11
|
+
for (const listener of current) listener(state);
|
|
12
|
+
},
|
|
13
|
+
subscribe(listener) {
|
|
14
|
+
listeners.add(listener);
|
|
15
|
+
return () => {
|
|
16
|
+
listeners.delete(listener);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/sdk.ts
|
|
23
|
+
/**
|
|
24
|
+
* Render `error` and its whole cause chain as indented JSON.
|
|
25
|
+
*
|
|
26
|
+
* Walks the chain because a cause nests (a `CoreSDKError` wrapping a thrown
|
|
27
|
+
* transport object wrapping a response body). Skips `stack` — a backend
|
|
28
|
+
* refusal is not diagnosed from the frames that asked, and the raw error is
|
|
29
|
+
* one `console.error` away for the cases that are. Reads `name`/`message` by
|
|
30
|
+
* hand because `Error` declares both non-enumerable, so `Object.entries`
|
|
31
|
+
* alone would return an Error as `{}`.
|
|
32
|
+
*
|
|
33
|
+
* **`cause` is read by hand for exactly the same reason**, and it was missed
|
|
34
|
+
* the first time — so this file shipped without the one property it exists to
|
|
35
|
+
* print. Verified against the real class: `Object.keys(new CoreSDKError({…}))`
|
|
36
|
+
* is `["source", "operation", "_tag"]`, and `cause`'s own descriptor carries
|
|
37
|
+
* `enumerable: false`. `Object.entries` therefore never saw it, and every
|
|
38
|
+
* `CoreSDKError` logged as the five bland fields above with the sentence that
|
|
39
|
+
* names the fault dropped.
|
|
40
|
+
*/
|
|
41
|
+
function describeSdkError(error) {
|
|
42
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
43
|
+
const unwrap = (value, depth) => {
|
|
44
|
+
if (value === null || typeof value !== "object") return value;
|
|
45
|
+
if (depth > 6) return "[truncated]";
|
|
46
|
+
if (seen.has(value)) return "[circular]";
|
|
47
|
+
seen.add(value);
|
|
48
|
+
if (Array.isArray(value)) return value.map((entry) => unwrap(entry, depth + 1));
|
|
49
|
+
const out = {};
|
|
50
|
+
if (value instanceof Error) {
|
|
51
|
+
out["name"] = value.name;
|
|
52
|
+
out["message"] = value.message;
|
|
53
|
+
if (value.cause !== void 0) out["cause"] = unwrap(value.cause, depth + 1);
|
|
54
|
+
}
|
|
55
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
56
|
+
if (key === "stack") continue;
|
|
57
|
+
out[key] = unwrap(entry, depth + 1);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
return JSON.stringify(unwrap(error, 0), null, 2) ?? String(error);
|
|
63
|
+
} catch {
|
|
64
|
+
return String(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Log a failed SDK call with the backend's reason, tagged by `scope`.
|
|
69
|
+
*
|
|
70
|
+
* `scope` names the SDK operation, not the component — `"quote.selectInsurance"`
|
|
71
|
+
* rather than `"the extras panel"`. What the console needs to answer is which
|
|
72
|
+
* call the backend refused; which button was on screen at the time is the part
|
|
73
|
+
* the reporter already knows.
|
|
74
|
+
*
|
|
75
|
+
* Logs the description ALONE. Callers that also have the raw error in hand may
|
|
76
|
+
* log it separately for a clickable stack; doubling it here would print every
|
|
77
|
+
* failure twice on the surfaces that already do.
|
|
78
|
+
*/
|
|
79
|
+
function logSdkFailure(scope, error) {
|
|
80
|
+
console.error(`[sdk] ${scope} failed:`, describeSdkError(error));
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/quote.ts
|
|
84
|
+
/** The two codes that mean "the stay WAS priced, but the rental's rate setup
|
|
85
|
+
* cannot be resolved to the single multi-rate a booking must be written
|
|
86
|
+
* against" — a v10 backend-configuration fault the guest has no part in.
|
|
87
|
+
*
|
|
88
|
+
* `resolveV10MultiRate` matches the priced quote's service lines against the
|
|
89
|
+
* rental's multi-rate rows for the policy, then tie-breaks on `baseRentRate`,
|
|
90
|
+
* which only fires when EXACTLY ONE tied row carries it. A rental whose PMS
|
|
91
|
+
* holds several equivalent base-rent rows per cancellation policy leaves the
|
|
92
|
+
* tie unbroken, and the SDK reports the quote unavailable rather than guess a
|
|
93
|
+
* rate — the price alone does not identify which row the booking belongs to.
|
|
94
|
+
*
|
|
95
|
+
* There is nothing an adopting site can do about the stay itself: it stays
|
|
96
|
+
* unbookable until the surplus rows are removed in the PMS. What the site owes
|
|
97
|
+
* the guest is honest copy — never the generic "not available", which asserts
|
|
98
|
+
* a booking conflict that does not exist. */
|
|
99
|
+
const MULTI_RATE_CODES = ["AmbiguousMultiRate", "UnresolvableMultiRate"];
|
|
100
|
+
/** True when an unavailable quote is a rate-configuration fault rather than a
|
|
101
|
+
* genuinely unbookable stay. Callers branch the MESSAGE on this and must keep
|
|
102
|
+
* booking disabled either way (SDK 2.16.0 MIGRATION, "Required Consumer
|
|
103
|
+
* Work"). */
|
|
104
|
+
function isRateConfigurationFault(code) {
|
|
105
|
+
return code !== void 0 && MULTI_RATE_CODES.includes(code);
|
|
106
|
+
}
|
|
107
|
+
/** Flatten `quote.insurance` into the renderable choice, or undefined when the
|
|
108
|
+
* feed offers no real product — insurance switched off in the hub setup, or a
|
|
109
|
+
* backend that has none. On v10 the SDK attaches a fixed no-insurance
|
|
110
|
+
* placeholder to every quote, so this returns undefined there and every
|
|
111
|
+
* insurance surface downstream self-hides without a backend check anywhere. */
|
|
112
|
+
function adaptInsurance(insurance) {
|
|
113
|
+
if (!insurance) return void 0;
|
|
114
|
+
const options = insurance.options.flatMap((option) => option.kind === "insurance" ? [{
|
|
115
|
+
id: option.id,
|
|
116
|
+
label: option.label,
|
|
117
|
+
formattedPrice: option.formattedPrice,
|
|
118
|
+
description: option.description,
|
|
119
|
+
logoUrl: option.logoUrl,
|
|
120
|
+
features: option.features ? [...option.features] : void 0,
|
|
121
|
+
resources: option.resources ? option.resources.map((r) => ({
|
|
122
|
+
label: r.label,
|
|
123
|
+
url: r.url
|
|
124
|
+
})) : void 0,
|
|
125
|
+
requiresTravelers: option.requiredInformation.kind === "travelers"
|
|
126
|
+
}] : []);
|
|
127
|
+
if (options.length === 0) return void 0;
|
|
128
|
+
return {
|
|
129
|
+
noneLabel: insurance.options.find((option) => option.kind === "none")?.label ?? "Keine Versicherung",
|
|
130
|
+
options,
|
|
131
|
+
selectedId: insurance.selected.kind === "insurance" ? insurance.selected.id : null
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Flatten a GuestQuote into the renderable view. Values verbatim.
|
|
135
|
+
*
|
|
136
|
+
* DISCOUNTS: the SDK does not hand us a discount LINE. When the PMS applies a
|
|
137
|
+
* price modifier (Frühbucher/Last-Minute, a voucher, a policy markup) it hangs
|
|
138
|
+
* it off the charge line as `modifiers[]` — label plus signed amount — and puts
|
|
139
|
+
* the pre-modifier figure in `totalBeforeModifiers`, leaving `appliedCharge`
|
|
140
|
+
* already net. Render `appliedCharge` alone (what every adoption did until now)
|
|
141
|
+
* and the saving is invisible: the guest sees only the reduced number and is
|
|
142
|
+
* never told a discount applied.
|
|
143
|
+
*
|
|
144
|
+
* So each modifier becomes its own row, and the line above it switches to
|
|
145
|
+
* `totalBeforeModifiers`. That pairing is not cosmetic — it is what makes the
|
|
146
|
+
* card add up. A deduction row under an `appliedCharge` that has ALREADY been
|
|
147
|
+
* reduced double-counts on sight: the column no longer sums to the total, and a
|
|
148
|
+
* guest checking the arithmetic finds it wrong. Gross line, then the deduction,
|
|
149
|
+
* then the remaining charges: 1.694,00 − 140,00 + 42,70 = 1.596,70.
|
|
150
|
+
*
|
|
151
|
+
* `bookingCardInformation.totalBeforeModifiers` is deliberately NOT rendered.
|
|
152
|
+
* The deduction rows already carry the saving, and a struck-through "was" price
|
|
153
|
+
* would state it a second time — as the one figure on the card that cannot be
|
|
154
|
+
* checked by adding, and as a price claim the customer would have to stand
|
|
155
|
+
* behind. The total stays the single net number.
|
|
156
|
+
*/
|
|
157
|
+
function adaptGuestQuote(quote) {
|
|
158
|
+
const lines = [];
|
|
159
|
+
for (const section of quote.bookingCardInformation.sections) for (const line of section.lines) {
|
|
160
|
+
lines.push({
|
|
161
|
+
label: line.item.position,
|
|
162
|
+
amount: line.item.totalBeforeModifiers ?? line.item.appliedCharge,
|
|
163
|
+
nested: false,
|
|
164
|
+
discount: false
|
|
165
|
+
});
|
|
166
|
+
for (const sub of line.subItems ?? []) {
|
|
167
|
+
lines.push({
|
|
168
|
+
label: sub.position,
|
|
169
|
+
amount: sub.totalBeforeModifiers ?? sub.appliedCharge,
|
|
170
|
+
nested: true,
|
|
171
|
+
discount: false
|
|
172
|
+
});
|
|
173
|
+
for (const mod of sub.modifiers ?? []) lines.push({
|
|
174
|
+
label: mod.label,
|
|
175
|
+
amount: mod.amount,
|
|
176
|
+
nested: true,
|
|
177
|
+
discount: true
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
for (const mod of line.item.modifiers ?? []) lines.push({
|
|
181
|
+
label: mod.label,
|
|
182
|
+
amount: mod.amount,
|
|
183
|
+
nested: false,
|
|
184
|
+
discount: true
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
card: {
|
|
189
|
+
lines,
|
|
190
|
+
total: quote.bookingCardInformation.total
|
|
191
|
+
},
|
|
192
|
+
services: quote.additionalServices.map((s) => ({
|
|
193
|
+
id: s.id,
|
|
194
|
+
label: s.label,
|
|
195
|
+
charge: s.charge,
|
|
196
|
+
description: s.description,
|
|
197
|
+
maxPerBooking: s.maxPerBooking,
|
|
198
|
+
proposedQuantity: s.proposedQuantity ?? s.preSelectedQuantity ?? 0,
|
|
199
|
+
available: s.available !== false,
|
|
200
|
+
unavailableReason: s.unavailableReason
|
|
201
|
+
})),
|
|
202
|
+
policies: (quote.cancellationPolicies ?? []).map((p) => ({
|
|
203
|
+
id: p.id,
|
|
204
|
+
label: p.label,
|
|
205
|
+
selected: p.selected,
|
|
206
|
+
description: p.description,
|
|
207
|
+
markUpPercentage: p.markUpPercentage,
|
|
208
|
+
processingFee: p.processingFee,
|
|
209
|
+
rules: (p.rules ?? []).map((r) => ({
|
|
210
|
+
daysBeforeArrival: r.daysBeforeArrival,
|
|
211
|
+
refundPercentage: r.refundPercentage
|
|
212
|
+
})),
|
|
213
|
+
available: p.available !== false,
|
|
214
|
+
unavailableReason: p.unavailableReason
|
|
215
|
+
})),
|
|
216
|
+
voucher: quote.voucher ? {
|
|
217
|
+
code: quote.voucher.code,
|
|
218
|
+
status: quote.voucher.status,
|
|
219
|
+
message: quote.voucher.message
|
|
220
|
+
} : void 0,
|
|
221
|
+
insurance: adaptInsurance(quote.insurance)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** Price a stay for `rentalId`. The caller owns the SDK instance and its
|
|
225
|
+
* lifecycle — pass a shared one to reuse its queue + cache across the caller's
|
|
226
|
+
* lifetime. `voucher` re-prices with a code (the PMS decides what it's worth). */
|
|
227
|
+
async function fetchQuote(rentalId, period, occupancy, sdk, voucher) {
|
|
228
|
+
const result = await sdk.live.quote.quote({
|
|
229
|
+
locale: "de-DE",
|
|
230
|
+
rentalId,
|
|
231
|
+
period,
|
|
232
|
+
occupancy: {
|
|
233
|
+
adults: occupancy.adults,
|
|
234
|
+
children: occupancy.children,
|
|
235
|
+
babies: occupancy.babies,
|
|
236
|
+
pets: occupancy.pets,
|
|
237
|
+
...occupancy.childrenAges !== void 0 ? { childrenAges: occupancy.childrenAges } : {}
|
|
238
|
+
},
|
|
239
|
+
...voucher !== void 0 ? { voucher } : {}
|
|
240
|
+
});
|
|
241
|
+
if (result.status !== "available") return {
|
|
242
|
+
status: "unavailable",
|
|
243
|
+
reason: result.reason,
|
|
244
|
+
code: result.code
|
|
245
|
+
};
|
|
246
|
+
const view = adaptGuestQuote(result.quote);
|
|
247
|
+
return {
|
|
248
|
+
status: "available",
|
|
249
|
+
quote: view.card,
|
|
250
|
+
live: result.quote,
|
|
251
|
+
view
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** Pull a human reason from a rejected selection when the SDK provides one.
|
|
255
|
+
* Today only `UnavailableQuoteCombination` carries a `reason`; anything else
|
|
256
|
+
* returns undefined so the caller stays quiet (the UI already guards the
|
|
257
|
+
* guessable failures). Kept structural so a new reason-bearing error tag can
|
|
258
|
+
* be added without a type dependency. */
|
|
259
|
+
function selectionErrorReason(error) {
|
|
260
|
+
if (error !== null && typeof error === "object" && "_tag" in error && error._tag === "UnavailableQuoteCombination" && "reason" in error && typeof error.reason === "string") return error.reason;
|
|
261
|
+
}
|
|
262
|
+
async function addQuoteService(quote, id, sdk) {
|
|
263
|
+
try {
|
|
264
|
+
const result = await sdk.live.quote.addAdditionalService({
|
|
265
|
+
quote,
|
|
266
|
+
id
|
|
267
|
+
});
|
|
268
|
+
return result.ok ? {
|
|
269
|
+
ok: true,
|
|
270
|
+
quote: result.quote
|
|
271
|
+
} : {
|
|
272
|
+
ok: false,
|
|
273
|
+
reason: selectionErrorReason(result.error)
|
|
274
|
+
};
|
|
275
|
+
} catch (error) {
|
|
276
|
+
logSdkFailure("quote.addAdditionalService", error);
|
|
277
|
+
return { ok: false };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function removeQuoteService(quote, id, sdk) {
|
|
281
|
+
try {
|
|
282
|
+
const result = await sdk.live.quote.removeAdditionalService({
|
|
283
|
+
quote,
|
|
284
|
+
id
|
|
285
|
+
});
|
|
286
|
+
return result.ok ? {
|
|
287
|
+
ok: true,
|
|
288
|
+
quote: result.quote
|
|
289
|
+
} : {
|
|
290
|
+
ok: false,
|
|
291
|
+
reason: selectionErrorReason(result.error)
|
|
292
|
+
};
|
|
293
|
+
} catch (error) {
|
|
294
|
+
logSdkFailure("quote.removeAdditionalService", error);
|
|
295
|
+
return { ok: false };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** Drop every additional-service selection from `quote`.
|
|
299
|
+
*
|
|
300
|
+
* A fresh quote's selection state is not knowable from the service list. It is
|
|
301
|
+
* empty by default and carries the PMS's proposed quantities only when the
|
|
302
|
+
* project sets `shouldAddProposedAdditionalServiceAmount: true` — an SDK
|
|
303
|
+
* option a component cannot see — and the quote will not say which happened
|
|
304
|
+
* (`GuestQuote.selections` is behind an unexported symbol). Rather than branch
|
|
305
|
+
* on something invisible, a caller that needs a known starting point clears
|
|
306
|
+
* first and steps up from zero with `addQuoteService`. */
|
|
307
|
+
async function clearQuoteServices(quote, sdk) {
|
|
308
|
+
try {
|
|
309
|
+
const result = await sdk.live.quote.clearAdditionalServices({ quote });
|
|
310
|
+
return result.ok ? {
|
|
311
|
+
ok: true,
|
|
312
|
+
quote: result.quote
|
|
313
|
+
} : {
|
|
314
|
+
ok: false,
|
|
315
|
+
reason: selectionErrorReason(result.error)
|
|
316
|
+
};
|
|
317
|
+
} catch (error) {
|
|
318
|
+
logSdkFailure("quote.clearAdditionalServices", error);
|
|
319
|
+
return { ok: false };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function selectQuotePolicy(quote, id, sdk) {
|
|
323
|
+
try {
|
|
324
|
+
const result = await sdk.live.quote.selectCancellationPolicy({
|
|
325
|
+
quote,
|
|
326
|
+
id
|
|
327
|
+
});
|
|
328
|
+
return result.ok ? {
|
|
329
|
+
ok: true,
|
|
330
|
+
quote: result.quote
|
|
331
|
+
} : {
|
|
332
|
+
ok: false,
|
|
333
|
+
reason: selectionErrorReason(result.error)
|
|
334
|
+
};
|
|
335
|
+
} catch (error) {
|
|
336
|
+
logSdkFailure("quote.selectCancellationPolicy", error);
|
|
337
|
+
return { ok: false };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Select a travel-insurance product (`id`) or none (`null`). Unlike the
|
|
341
|
+
* service/policy calls the SDK returns the new GuestQuote directly and throws
|
|
342
|
+
* on failure (unknown id) — normalized here to the same QuoteSelectionResult
|
|
343
|
+
* the other selection helpers use, so a stale id is a silent no-op the caller
|
|
344
|
+
* handles by keeping the previous quote.
|
|
345
|
+
*
|
|
346
|
+
* For a product that needs no traveler data this is the COMPLETE selection:
|
|
347
|
+
* the premium books with the rental via `booking.book`, which reads the plan
|
|
348
|
+
* off the quote. A product with `requiresTravelers` needs the pre-contract /
|
|
349
|
+
* payment / `bookInsurance` steps on top, which the checkout does not
|
|
350
|
+
* implement — it offers only the products it can complete. */
|
|
351
|
+
async function selectQuoteInsurance(quote, id, sdk) {
|
|
352
|
+
try {
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
quote: await sdk.live.quote.selectInsurance({
|
|
356
|
+
quote,
|
|
357
|
+
selection: id === null ? { kind: "none" } : {
|
|
358
|
+
kind: "insurance",
|
|
359
|
+
id
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
};
|
|
363
|
+
} catch (error) {
|
|
364
|
+
logSdkFailure("quote.selectInsurance", error);
|
|
365
|
+
return { ok: false };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The concrete "free cancellation until" date for ONE policy — DERIVED, not
|
|
370
|
+
* fabricated: the PMS states its rules relatively (`daysBeforeArrival` +
|
|
371
|
+
* `refundPercentage`), so with the chosen arrival in hand the deadline is
|
|
372
|
+
* plain arithmetic (arrival − daysBeforeArrival), the same category of
|
|
373
|
+
* derivation as computing nights from a range.
|
|
374
|
+
*
|
|
375
|
+
* Uses the policy's full-refund (100%) rule. Returns null when the policy has
|
|
376
|
+
* no full-refund rule OR the derived date is already in the past (booking
|
|
377
|
+
* inside the window — advertising a bygone deadline would be wrong).
|
|
378
|
+
*/
|
|
379
|
+
/** The policy's full-refund rule, if it has one. The refund figure is the SDK's
|
|
380
|
+
* own formatted string ("100%"), so this reads it rather than comparing a
|
|
381
|
+
* number — the one place that pattern is written, because both the derived
|
|
382
|
+
* deadline and the "is this a refundable rate at all" question depend on it. */
|
|
383
|
+
function fullRefundRule(policy) {
|
|
384
|
+
return policy?.rules.find((r) => /^\s*100\s*%/.test(r.refundPercentage));
|
|
385
|
+
}
|
|
386
|
+
function policyFreeCancellationUntil(policy, arrival) {
|
|
387
|
+
if (!policy) return null;
|
|
388
|
+
const fullRefund = fullRefundRule(policy);
|
|
389
|
+
if (!fullRefund) return null;
|
|
390
|
+
const until = new Date(arrival);
|
|
391
|
+
until.setHours(0, 0, 0, 0);
|
|
392
|
+
until.setDate(until.getDate() - fullRefund.daysBeforeArrival);
|
|
393
|
+
const today = /* @__PURE__ */ new Date();
|
|
394
|
+
today.setHours(0, 0, 0, 0);
|
|
395
|
+
return until >= today ? until : null;
|
|
396
|
+
}
|
|
397
|
+
/** The deadline for the stay as it stands — i.e. of the SELECTED policy.
|
|
398
|
+
* Kept as its own name because that is what a summary line means: not "some
|
|
399
|
+
* policy allows this" but "what you have chosen allows this". */
|
|
400
|
+
function freeCancellationUntil(policies, arrival) {
|
|
401
|
+
return policyFreeCancellationUntil(policies.find((p) => p.selected) ?? policies[0], arrival);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Price every selectable cancellation policy off ONE base quote, so a guest can
|
|
405
|
+
* compare the policies by what each actually costs rather than by a percentage.
|
|
406
|
+
*
|
|
407
|
+
* WHY REAL BRANCHES AND NOT ARITHMETIC. `markUpPercentage` looks like enough to
|
|
408
|
+
* compute the alternative price, and it is not: the modifier applies to the
|
|
409
|
+
* RENT, while taxes and fees ride along untouched, so the change it makes to
|
|
410
|
+
* the total is always smaller than the percentage says — and a guest who checks
|
|
411
|
+
* the number against the total would find the site's own figure wrong. The
|
|
412
|
+
* arithmetic is unavailable in any case: every amount the SDK returns is a
|
|
413
|
+
* locale-formatted string, so computing on one means parsing it back, which
|
|
414
|
+
* this file does for no other charge either. What the PMS prices is what the UI
|
|
415
|
+
* shows.
|
|
416
|
+
*
|
|
417
|
+
* `selectCancellationPolicy` does NOT mutate its input — it returns a new quote
|
|
418
|
+
* — so each policy can be branched off the same base and the branches held side
|
|
419
|
+
* by side. Costs one SDK call per policy MINUS one: the already-selected policy
|
|
420
|
+
* is the base quote itself. Sequential, because the API allows 1 req/s.
|
|
421
|
+
*
|
|
422
|
+
* A policy the PMS marks `available: false` is skipped rather than attempted:
|
|
423
|
+
* selecting it would fail by definition, and a burnt request inside a 1 req/s
|
|
424
|
+
* budget delays the ones that can succeed. It simply gets no branch, and the UI
|
|
425
|
+
* renders it as the unselectable row it already is.
|
|
426
|
+
*/
|
|
427
|
+
async function pricePolicyBranches(base, sdk) {
|
|
428
|
+
const branches = [];
|
|
429
|
+
for (const policy of base.cancellationPolicies ?? []) {
|
|
430
|
+
if (policy.selected) {
|
|
431
|
+
branches.push({
|
|
432
|
+
id: policy.id,
|
|
433
|
+
quote: base,
|
|
434
|
+
view: adaptGuestQuote(base)
|
|
435
|
+
});
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (policy.available === false) continue;
|
|
439
|
+
const result = await selectQuotePolicy(base, policy.id, sdk);
|
|
440
|
+
if (!result.ok) continue;
|
|
441
|
+
branches.push({
|
|
442
|
+
id: policy.id,
|
|
443
|
+
quote: result.quote,
|
|
444
|
+
view: adaptGuestQuote(result.quote)
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return branches;
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/booking-snapshot.ts
|
|
451
|
+
/** Rental identity, parked by the checkout page (which has it from SSR) and
|
|
452
|
+
* folded into the snapshot at book time — the SDK seam that writes the
|
|
453
|
+
* snapshot never sees a rental name or a link. */
|
|
454
|
+
const CHECKOUT_CONTEXT_KEY = "voffice:checkout-context";
|
|
455
|
+
/** The finished record `/buchen/bestaetigung` reads. */
|
|
456
|
+
const BOOKING_SNAPSHOT_KEY = "voffice:booking-return";
|
|
457
|
+
/** `sessionStorage` throws outright in Safari's private mode and does not exist
|
|
458
|
+
* during SSR. NOTHING here may break a booking, so every access fails soft. */
|
|
459
|
+
function readStored(key) {
|
|
460
|
+
try {
|
|
461
|
+
return typeof window === "undefined" ? null : window.sessionStorage.getItem(key);
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function writeStored(key, value) {
|
|
467
|
+
try {
|
|
468
|
+
if (typeof window !== "undefined") window.sessionStorage.setItem(key, value);
|
|
469
|
+
} catch {}
|
|
470
|
+
}
|
|
471
|
+
/** Park the rental's identity for the return page. Called by the checkout page
|
|
472
|
+
* on load — it is the last place in the flow that knows the rental by name. */
|
|
473
|
+
function saveCheckoutContext(context) {
|
|
474
|
+
writeStored(CHECKOUT_CONTEXT_KEY, JSON.stringify(context));
|
|
475
|
+
}
|
|
476
|
+
function readCheckoutContext() {
|
|
477
|
+
const raw = readStored(CHECKOUT_CONTEXT_KEY);
|
|
478
|
+
if (!raw) return void 0;
|
|
479
|
+
try {
|
|
480
|
+
const parsed = JSON.parse(raw);
|
|
481
|
+
return typeof parsed.rentalName === "string" && parsed.rentalName ? parsed : void 0;
|
|
482
|
+
} catch {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The stay, read off the checkout URL rather than passed in: the island keeps
|
|
488
|
+
* `?start`/`?end`/`?adults`/… in sync with the edited stay through
|
|
489
|
+
* `replaceState`, so the address bar IS the live value at the moment of
|
|
490
|
+
* booking — and `submitBooking` stays the two-argument SDK seam it is.
|
|
491
|
+
*/
|
|
492
|
+
function readStay() {
|
|
493
|
+
if (typeof window === "undefined") return {};
|
|
494
|
+
const params = new URLSearchParams(window.location.search);
|
|
495
|
+
const iso = (key) => {
|
|
496
|
+
const value = params.get(key);
|
|
497
|
+
return value && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : void 0;
|
|
498
|
+
};
|
|
499
|
+
const count = (key) => {
|
|
500
|
+
const value = Number(params.get(key));
|
|
501
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
502
|
+
};
|
|
503
|
+
const adults = count("adults");
|
|
504
|
+
return {
|
|
505
|
+
start: iso("start"),
|
|
506
|
+
end: iso("end"),
|
|
507
|
+
guests: adults > 0 ? {
|
|
508
|
+
adults,
|
|
509
|
+
children: count("children"),
|
|
510
|
+
babies: count("babies"),
|
|
511
|
+
pets: count("pets")
|
|
512
|
+
} : void 0
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Write the return page's record. Called by `submitBooking` the moment the
|
|
517
|
+
* reservation exists — EARLIER than the SDK's "before submitting a payment
|
|
518
|
+
* option", because that is when the booking number is born, and a guest who
|
|
519
|
+
* settles by bank transfer never leaves the checkout at all.
|
|
520
|
+
*
|
|
521
|
+
* Never throws. The snapshot is a nicety; the booking is not.
|
|
522
|
+
*/
|
|
523
|
+
function saveBookingSnapshot(bookingNumber, quote) {
|
|
524
|
+
try {
|
|
525
|
+
const context = readCheckoutContext();
|
|
526
|
+
const snapshot = {
|
|
527
|
+
bookingNumber,
|
|
528
|
+
...context?.rentalName ? { rentalName: context.rentalName } : {},
|
|
529
|
+
...context?.location ? { location: context.location } : {},
|
|
530
|
+
...context?.href ? { href: context.href } : {},
|
|
531
|
+
...context?.images?.length ? { images: context.images } : {},
|
|
532
|
+
...readStay(),
|
|
533
|
+
total: adaptGuestQuote(quote).card.total
|
|
534
|
+
};
|
|
535
|
+
writeStored(BOOKING_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
|
536
|
+
} catch {}
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region src/booking.ts
|
|
540
|
+
/** Where the payment provider sends the guest back after an online payment
|
|
541
|
+
* (relative — the SDK/PSP resolves it against the site origin). */
|
|
542
|
+
const CHECKOUT_RETURN_PATH = "/buchen/bestaetigung";
|
|
543
|
+
/**
|
|
544
|
+
* Create the booking. `quote` MUST be the live GuestQuote instance (with any
|
|
545
|
+
* service/policy selections applied). USER-initiated only — this reserves for
|
|
546
|
+
* real. Throws on transport/backend errors (the caller renders an honest
|
|
547
|
+
* retry message; nothing was necessarily booked when it throws).
|
|
548
|
+
*/
|
|
549
|
+
async function submitBooking(quote, contact, sdk) {
|
|
550
|
+
const out = await sdk.live.booking.book({
|
|
551
|
+
quote,
|
|
552
|
+
contact: {
|
|
553
|
+
forename: contact.forename,
|
|
554
|
+
surname: contact.surname,
|
|
555
|
+
...contact.title ? { title: contact.title } : {},
|
|
556
|
+
address: {
|
|
557
|
+
street: contact.street,
|
|
558
|
+
...contact.housenumber ? { housenumber: contact.housenumber } : {},
|
|
559
|
+
postalcode: contact.postalcode,
|
|
560
|
+
city: contact.city,
|
|
561
|
+
countryCode: contact.countryCode
|
|
562
|
+
},
|
|
563
|
+
contactDetails: contact.phone ? {
|
|
564
|
+
email: contact.email,
|
|
565
|
+
phone: contact.phone
|
|
566
|
+
} : { email: contact.email },
|
|
567
|
+
...contact.note ? { note: contact.note } : {}
|
|
568
|
+
},
|
|
569
|
+
relativeRedirectUrl: CHECKOUT_RETURN_PATH
|
|
570
|
+
}).catch((error) => {
|
|
571
|
+
logSdkFailure("booking.book", error);
|
|
572
|
+
throw error;
|
|
573
|
+
});
|
|
574
|
+
saveBookingSnapshot(out.bookingNumber, quote);
|
|
575
|
+
const toView = (entry) => ({
|
|
576
|
+
kind: entry.kind,
|
|
577
|
+
label: entry.label,
|
|
578
|
+
dueOn: entry.dueOn,
|
|
579
|
+
paymentAt: entry.paymentAt,
|
|
580
|
+
lines: (entry.lines ?? []).map((l) => ({
|
|
581
|
+
label: l.label,
|
|
582
|
+
amount: l.amount
|
|
583
|
+
})),
|
|
584
|
+
amount: entry.amount,
|
|
585
|
+
options: [...entry.paymentOptions ?? []]
|
|
586
|
+
});
|
|
587
|
+
return {
|
|
588
|
+
bookingNumber: out.bookingNumber,
|
|
589
|
+
schedules: out.payment.schedule.map(toView),
|
|
590
|
+
alternatives: out.payment.alternatives.map(toView),
|
|
591
|
+
insurancePayments: (out.insurancePaymentOptions ?? []).flatMap((option) => option.kind === "external_url" ? [{
|
|
592
|
+
label: option.label,
|
|
593
|
+
url: option.url,
|
|
594
|
+
description: option.description
|
|
595
|
+
}] : [])
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/** True for the option kinds `submitPaymentOption` can perform in-browser
|
|
599
|
+
* (redirect / form_post / stripe_checkout). Bank transfer is display-only. */
|
|
600
|
+
function isSubmittableOption(option) {
|
|
601
|
+
return option.kind !== "bank_transfer";
|
|
602
|
+
}
|
|
603
|
+
/** Perform an online payment option (redirects the browser to the PSP).
|
|
604
|
+
* USER-initiated only.
|
|
605
|
+
*
|
|
606
|
+
* Logged on failure, and this is the worst place in the flow to lose a reason:
|
|
607
|
+
* by the time a payment option is on screen THE RESERVATION ALREADY EXISTS
|
|
608
|
+
* (the SDK's `booking.md` says as much), so a failure here is an unpaid
|
|
609
|
+
* booking, not a failed one. The checkout's catch discards the error entirely
|
|
610
|
+
* — `.catch(() => setPayError(true))` — so without this a guest who cannot
|
|
611
|
+
* pay leaves no trace at all, on a booking someone now has to chase. */
|
|
612
|
+
async function payWithOption(option, sdk) {
|
|
613
|
+
await sdk.live.booking.submitPaymentOption(option).catch((error) => {
|
|
614
|
+
logSdkFailure("booking.submitPaymentOption", error);
|
|
615
|
+
throw error;
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
//#endregion
|
|
619
|
+
//#region src/checkout/card-order.ts
|
|
620
|
+
function toBlocks(lines) {
|
|
621
|
+
const blocks = [];
|
|
622
|
+
for (const line of lines) if (!line.nested || blocks.length === 0) blocks.push({
|
|
623
|
+
key: line.label,
|
|
624
|
+
lines: [line]
|
|
625
|
+
});
|
|
626
|
+
else blocks[blocks.length - 1].lines.push(line);
|
|
627
|
+
return blocks;
|
|
628
|
+
}
|
|
629
|
+
function stabilizeCardOrder(view, order) {
|
|
630
|
+
const blocks = toBlocks(view.card.lines);
|
|
631
|
+
for (const block of blocks) if (!order.has(block.key)) order.set(block.key, order.size);
|
|
632
|
+
const sorted = [...blocks].sort((a, b) => order.get(a.key) - order.get(b.key));
|
|
633
|
+
return {
|
|
634
|
+
...view,
|
|
635
|
+
card: {
|
|
636
|
+
...view.card,
|
|
637
|
+
lines: sorted.flatMap((b) => b.lines)
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/checkout/insurance.ts
|
|
643
|
+
/** Documents the insurer ships that are NOT part of the pre-contractual set,
|
|
644
|
+
* and are therefore not surfaced at all — neither listed nor gated.
|
|
645
|
+
*
|
|
646
|
+
* One entry so far: `commonProductInformationsUrl`, which the SDK labels
|
|
647
|
+
* "Produktinformationen" and which is in fact the GDV Einwilligungs- und
|
|
648
|
+
* Schweigepflichtentbindungserklärung for health data. It is a consent the
|
|
649
|
+
* customer GIVES (§ 213 VVG, Art. 9 DSGVO), not information the insurer OWES:
|
|
650
|
+
* § 1 VVG-InfoV's twenty items contain no consent declaration, the document is
|
|
651
|
+
* generic GDV boilerplate identical across tariffs and hosted away from the
|
|
652
|
+
* contract documents, and it becomes operative at claim time rather than at
|
|
653
|
+
* conclusion. The insurer's own funnels do not surface it before conclusion
|
|
654
|
+
* either, which is the strongest evidence available for what it considers
|
|
655
|
+
* pre-contractually required.
|
|
656
|
+
*
|
|
657
|
+
* Matched on the URL rather than the label, because labels are localized
|
|
658
|
+
* ("Produktinformationen" / "Product information") and a site may override
|
|
659
|
+
* them, whereas the file is one fixed document.
|
|
660
|
+
*
|
|
661
|
+
* This is an EXCLUSION list, and that is why it is acceptable where an
|
|
662
|
+
* inclusion whitelist would not be. The two fail in opposite directions: if
|
|
663
|
+
* this pattern stops matching, the document simply reappears — a cosmetic
|
|
664
|
+
* regression. If an inclusion whitelist stops matching, a mandatory document
|
|
665
|
+
* silently vanishes — a legal defect nobody sees until a dispute. */
|
|
666
|
+
const NON_PRECONTRACTUAL_RESOURCE = /Einwilligung_Erhebung/i;
|
|
667
|
+
/** The plan's documents as the checkout treats them: what it lists, what the
|
|
668
|
+
* guest must open, and what makes a plan sellable at all read the SAME list,
|
|
669
|
+
* so a hidden document can never end up required-but-unlisted (which would be
|
|
670
|
+
* a gate the guest cannot close). */
|
|
671
|
+
function shownInsuranceResources(option) {
|
|
672
|
+
return (option.resources ?? []).filter((resource) => !NON_PRECONTRACTUAL_RESOURCE.test(resource.url));
|
|
673
|
+
}
|
|
674
|
+
/** The insurance products a checkout can actually complete. Two gates, and
|
|
675
|
+
* this filter is where the layer's capability is declared — a site that adds a
|
|
676
|
+
* missing flow adds it HERE, upstream, never in its own copy.
|
|
677
|
+
*
|
|
678
|
+
* 1. **Technical.** Products that select with the id alone and book WITH the
|
|
679
|
+
* rental booking. One that demands per-traveler data first (traveler forms,
|
|
680
|
+
* pre-contract, SEPA mandate, a post-booking insurance call) needs a flow
|
|
681
|
+
* this layer does not implement — offering it would dead-end the guest.
|
|
682
|
+
*
|
|
683
|
+
* 2. **Legal.** The product must carry its `resources`, the insurer's
|
|
684
|
+
* pre-contractual documents (see "Mandatory pre-contractual documents"
|
|
685
|
+
* below for which duties they discharge). A product the feed sends WITHOUT
|
|
686
|
+
* them cannot be concluded lawfully here, so it self-hides rather than
|
|
687
|
+
* being sold undocumented — the same rule as (1), applied to a legal
|
|
688
|
+
* precondition instead of a technical one. */
|
|
689
|
+
function selectableInsuranceOptions(insurance) {
|
|
690
|
+
return insurance.options.filter((option) => !option.requiresTravelers && shownInsuranceResources(option).length > 0);
|
|
691
|
+
}
|
|
692
|
+
/** The selected plan, but only when it is one the guest could actually pick
|
|
693
|
+
* here — so no price row can name a product the section above never showed. */
|
|
694
|
+
function selectedInsuranceOption(insurance) {
|
|
695
|
+
if (!insurance) return void 0;
|
|
696
|
+
return selectableInsuranceOptions(insurance).find((option) => option.id === insurance.selectedId);
|
|
697
|
+
}
|
|
698
|
+
/** The identity of an insurance PRODUCT, as opposed to the id of one priced
|
|
699
|
+
* variant of it.
|
|
700
|
+
*
|
|
701
|
+
* An insurer may band its tariffs by trip value, and then a plan id is not the
|
|
702
|
+
* product — it is the product AT one price. The same tariff comes back under a
|
|
703
|
+
* new id and a new premium the moment the total crosses a band. URV is the
|
|
704
|
+
* observed case: "RRV ohne SB Deutschland" is `RROR0625` at 56,00 € up to
|
|
705
|
+
* roughly 1.250 €, `RROR0725` at 65,00 € above that, `RROR0825` at 74,00 €
|
|
706
|
+
* above that again. Every extra, every policy markup and every stay change can
|
|
707
|
+
* cross a band, and each of them re-prices the quote — which is what makes the
|
|
708
|
+
* SDK re-fetch the insurer's plan list against the new total.
|
|
709
|
+
*
|
|
710
|
+
* The label is what survives that: the same string in every band, and the name
|
|
711
|
+
* the guest actually chose, since it is what the radio shows. */
|
|
712
|
+
function insuranceProductKey(option) {
|
|
713
|
+
return option.label;
|
|
714
|
+
}
|
|
715
|
+
/** The plan id to re-apply onto a re-priced quote, or null when there is
|
|
716
|
+
* nothing to do — no choice, already applied, or a product this feed no longer
|
|
717
|
+
* offers as a selectable one. The last case is why the feed is checked rather
|
|
718
|
+
* than the call simply attempted: a doomed selection would spend one of the
|
|
719
|
+
* rate-limited requests to be refused, and the guest ends up on "keine
|
|
720
|
+
* Versicherung" either way.
|
|
721
|
+
*
|
|
722
|
+
* Matched by id first, then by product (see insuranceProductKey). That
|
|
723
|
+
* fallback is what keeps a banded re-price from silently un-selecting
|
|
724
|
+
* insurance the feed still sells: the SDK carries a selection across its
|
|
725
|
+
* refresh only on an exact id match, so a renamed-but-identical tariff reads
|
|
726
|
+
* to it as a plan that vanished, and a guest who only added a towel package
|
|
727
|
+
* lands back on "keine Versicherung". Re-applying picks the SAME product at
|
|
728
|
+
* its new premium — the one they would in fact be buying — and that premium is
|
|
729
|
+
* on screen in the radio, in the summary rail and above the submit button, so
|
|
730
|
+
* nothing about the change is hidden.
|
|
731
|
+
*
|
|
732
|
+
* Checked against the SELECTABLE options, not the raw list: a plan the fresh
|
|
733
|
+
* feed carries without its documents (or newly demanding traveler data) is one
|
|
734
|
+
* the section no longer shows, so re-applying it would leave the quote
|
|
735
|
+
* carrying an insurance the page neither displays nor can take a document
|
|
736
|
+
* confirmation for. */
|
|
737
|
+
function insuranceIdToReapply(fresh, wanted) {
|
|
738
|
+
if (!wanted || !fresh.insurance) return null;
|
|
739
|
+
if (fresh.insurance.selectedId === wanted.id) return null;
|
|
740
|
+
const options = selectableInsuranceOptions(fresh.insurance);
|
|
741
|
+
const match = options.find((option) => option.id === wanted.id) ?? options.find((option) => insuranceProductKey(option) === insuranceProductKey(wanted));
|
|
742
|
+
if (!match || match.id === fresh.insurance.selectedId) return null;
|
|
743
|
+
return match.id;
|
|
744
|
+
}
|
|
745
|
+
const NO_INSURANCE_DOCS = {
|
|
746
|
+
opened: /* @__PURE__ */ new Set(),
|
|
747
|
+
confirmedProduct: null
|
|
748
|
+
};
|
|
749
|
+
/** True while a chosen plan's document step is unfinished. No plan chosen (or
|
|
750
|
+
* "keine Versicherung") is never blocking — the duty attaches to the insurance
|
|
751
|
+
* contract, not to the stay. */
|
|
752
|
+
function insuranceDocsPending(option, docs) {
|
|
753
|
+
if (!option) return false;
|
|
754
|
+
if (docs.confirmedProduct !== insuranceProductKey(option)) return true;
|
|
755
|
+
return !shownInsuranceResources(option).every((resource) => docs.opened.has(resource.url));
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/checkout/stay.ts
|
|
759
|
+
/** Date → `YYYY-MM-DD` in LOCAL time — the vocabulary the URL handover and the
|
|
760
|
+
* SDK both use. Local on purpose (matches date-fns `format(date,
|
|
761
|
+
* "yyyy-MM-dd")`, which the sites' own lib/dates.ts uses): a calendar day
|
|
762
|
+
* picked in the browser must not shift across midnight UTC. */
|
|
763
|
+
function isoDate(date) {
|
|
764
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
765
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
766
|
+
}
|
|
767
|
+
function occupancyOf(stay) {
|
|
768
|
+
return {
|
|
769
|
+
adults: stay.adults,
|
|
770
|
+
children: stay.children,
|
|
771
|
+
childrenAges: stay.childrenAges.length ? stay.childrenAges : void 0,
|
|
772
|
+
babies: stay.babies,
|
|
773
|
+
pets: stay.pets
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/** The identity of a stay for "is the shown price still the shown stay"
|
|
777
|
+
* comparisons — every field that changes the quote, in one string. */
|
|
778
|
+
function stayKeyOf(stay) {
|
|
779
|
+
return [
|
|
780
|
+
isoDate(stay.start),
|
|
781
|
+
isoDate(stay.end),
|
|
782
|
+
stay.adults,
|
|
783
|
+
stay.children,
|
|
784
|
+
stay.childrenAges.join(","),
|
|
785
|
+
stay.babies,
|
|
786
|
+
stay.pets
|
|
787
|
+
].join("|");
|
|
788
|
+
}
|
|
789
|
+
/** The stay as URL search params (`start`/`end`/`adults`/… — the same
|
|
790
|
+
* vocabulary the rental page reads + writes). */
|
|
791
|
+
function stayParams(stay) {
|
|
792
|
+
const params = new URLSearchParams();
|
|
793
|
+
params.set("start", isoDate(stay.start));
|
|
794
|
+
params.set("end", isoDate(stay.end));
|
|
795
|
+
params.set("adults", String(stay.adults));
|
|
796
|
+
if (stay.children > 0) {
|
|
797
|
+
params.set("children", String(stay.children));
|
|
798
|
+
if (stay.childrenAges.length) params.set("childrenAges", stay.childrenAges.join(","));
|
|
799
|
+
}
|
|
800
|
+
if (stay.babies > 0) params.set("babies", String(stay.babies));
|
|
801
|
+
if (stay.pets > 0) params.set("pets", String(stay.pets));
|
|
802
|
+
return params.toString();
|
|
803
|
+
}
|
|
804
|
+
//#endregion
|
|
805
|
+
//#region src/checkout/createCheckoutFlow.ts
|
|
806
|
+
const initialRaw = () => ({
|
|
807
|
+
phase: { kind: "loading" },
|
|
808
|
+
live: null,
|
|
809
|
+
view: null,
|
|
810
|
+
counts: {},
|
|
811
|
+
quotedKey: null,
|
|
812
|
+
issue: null,
|
|
813
|
+
busy: false,
|
|
814
|
+
voucherApplied: null,
|
|
815
|
+
voucherError: null,
|
|
816
|
+
selectionError: null,
|
|
817
|
+
insuranceDocs: NO_INSURANCE_DOCS,
|
|
818
|
+
bookState: "idle",
|
|
819
|
+
confirmation: null,
|
|
820
|
+
payBusyKey: null,
|
|
821
|
+
payError: false
|
|
822
|
+
});
|
|
823
|
+
function createCheckoutFlow(initialOptions) {
|
|
824
|
+
let opts = initialOptions;
|
|
825
|
+
const { rentalId, getSdk } = initialOptions;
|
|
826
|
+
let raw = initialRaw();
|
|
827
|
+
const store = createStore(derive(raw, opts));
|
|
828
|
+
let holds = 0;
|
|
829
|
+
let gen = 0;
|
|
830
|
+
const lineOrder = /* @__PURE__ */ new Map();
|
|
831
|
+
const stabilize = (v) => stabilizeCardOrder(v, lineOrder);
|
|
832
|
+
let started = false;
|
|
833
|
+
let timer = null;
|
|
834
|
+
const patch = (next) => {
|
|
835
|
+
raw = {
|
|
836
|
+
...raw,
|
|
837
|
+
...next
|
|
838
|
+
};
|
|
839
|
+
store.setState(derive(raw, opts));
|
|
840
|
+
schedule();
|
|
841
|
+
};
|
|
842
|
+
const hold = () => {
|
|
843
|
+
holds += 1;
|
|
844
|
+
patch({ busy: true });
|
|
845
|
+
};
|
|
846
|
+
const release = () => {
|
|
847
|
+
holds -= 1;
|
|
848
|
+
patch({ busy: holds > 0 });
|
|
849
|
+
};
|
|
850
|
+
let lastInputs = [];
|
|
851
|
+
const schedule = () => {
|
|
852
|
+
if (!started) return;
|
|
853
|
+
const { stay, chosenPolicy } = opts;
|
|
854
|
+
const stayKey = stay ? stayKeyOf(stay) : null;
|
|
855
|
+
const inputs = [
|
|
856
|
+
stay,
|
|
857
|
+
stayKey,
|
|
858
|
+
raw.quotedKey,
|
|
859
|
+
raw.issue,
|
|
860
|
+
raw.confirmation,
|
|
861
|
+
raw.voucherApplied,
|
|
862
|
+
raw.counts,
|
|
863
|
+
raw.view,
|
|
864
|
+
chosenPolicy
|
|
865
|
+
];
|
|
866
|
+
if (inputs.length === lastInputs.length && inputs.every((v, i) => v === lastInputs[i])) return;
|
|
867
|
+
lastInputs = inputs;
|
|
868
|
+
if (timer) clearTimeout(timer);
|
|
869
|
+
timer = null;
|
|
870
|
+
if (!stay || !stayKey || raw.confirmation) return;
|
|
871
|
+
if (stay.adults < 1) return;
|
|
872
|
+
if (raw.quotedKey === stayKey || raw.issue?.key === stayKey) return;
|
|
873
|
+
const isInitial = raw.view == null;
|
|
874
|
+
timer = setTimeout(() => {
|
|
875
|
+
timer = null;
|
|
876
|
+
loadQuote(stay, stayKey, raw.voucherApplied, raw.view ? raw.counts : null, raw.view?.policies.find((p) => p.selected)?.id ?? chosenPolicy, selectedInsuranceOption(raw.view?.insurance));
|
|
877
|
+
}, isInitial ? 0 : 350);
|
|
878
|
+
};
|
|
879
|
+
const loadQuote = async (targetStay, key, voucher, wantCounts, wantPolicy, wantInsurance) => {
|
|
880
|
+
const myGen = ++gen;
|
|
881
|
+
const hadView = raw.view != null;
|
|
882
|
+
hold();
|
|
883
|
+
try {
|
|
884
|
+
const result = await fetchQuote(rentalId, {
|
|
885
|
+
start: isoDate(targetStay.start),
|
|
886
|
+
end: isoDate(targetStay.end)
|
|
887
|
+
}, occupancyOf(targetStay), getSdk(), voucher ?? void 0);
|
|
888
|
+
if (myGen !== gen) return;
|
|
889
|
+
if (result.status !== "available") {
|
|
890
|
+
const rateFault = isRateConfigurationFault(result.code);
|
|
891
|
+
if (rateFault) opts.onRateConfigurationFault?.({
|
|
892
|
+
code: result.code,
|
|
893
|
+
rentalId
|
|
894
|
+
});
|
|
895
|
+
const reason = rateFault ? opts.messages.rateFault : result.reason || opts.messages.unavailable;
|
|
896
|
+
if (!hadView) patch({ phase: {
|
|
897
|
+
kind: "unavailable",
|
|
898
|
+
reason
|
|
899
|
+
} });
|
|
900
|
+
else patch({ issue: {
|
|
901
|
+
key,
|
|
902
|
+
reason,
|
|
903
|
+
retryable: false
|
|
904
|
+
} });
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
let quote = result.live;
|
|
908
|
+
const cleared = await clearQuoteServices(quote, getSdk());
|
|
909
|
+
if (!cleared.ok) throw new Error("clearAdditionalServices failed");
|
|
910
|
+
quote = cleared.quote;
|
|
911
|
+
let applied = null;
|
|
912
|
+
if (wantCounts) {
|
|
913
|
+
applied = {};
|
|
914
|
+
for (const service of result.view.services) {
|
|
915
|
+
const want = wantCounts[service.id] ?? 0;
|
|
916
|
+
let current = 0;
|
|
917
|
+
while (current < want) {
|
|
918
|
+
const next = await addQuoteService(quote, service.id, getSdk());
|
|
919
|
+
if (!next.ok) break;
|
|
920
|
+
quote = next.quote;
|
|
921
|
+
current++;
|
|
922
|
+
}
|
|
923
|
+
applied[service.id] = current;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const afterExtras = adaptGuestQuote(quote);
|
|
927
|
+
if (wantPolicy && opts.filterOfferedPolicies(afterExtras.policies, targetStay.start).some((p) => p.id === wantPolicy)) {
|
|
928
|
+
if (wantPolicy !== afterExtras.policies.find((p) => p.selected)?.id) {
|
|
929
|
+
const next = await selectQuotePolicy(quote, wantPolicy, getSdk());
|
|
930
|
+
if (next.ok) quote = next.quote;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
const reapplyInsurance = insuranceIdToReapply(adaptGuestQuote(quote), wantInsurance);
|
|
934
|
+
if (reapplyInsurance) {
|
|
935
|
+
const next = await selectQuoteInsurance(quote, reapplyInsurance, getSdk());
|
|
936
|
+
if (next.ok) quote = next.quote;
|
|
937
|
+
}
|
|
938
|
+
if (myGen !== gen) return;
|
|
939
|
+
const nextView = adaptGuestQuote(quote);
|
|
940
|
+
const voucherLapsed = voucher !== null && nextView.voucher?.status === "not_applied";
|
|
941
|
+
patch({
|
|
942
|
+
live: quote,
|
|
943
|
+
view: stabilize(nextView),
|
|
944
|
+
counts: applied ?? Object.fromEntries(nextView.services.map((s) => [s.id, 0])),
|
|
945
|
+
quotedKey: key,
|
|
946
|
+
issue: null,
|
|
947
|
+
phase: { kind: "ready" },
|
|
948
|
+
...voucherLapsed ? {
|
|
949
|
+
voucherApplied: null,
|
|
950
|
+
voucherError: nextView.voucher?.message || opts.messages.voucherRejected
|
|
951
|
+
} : {}
|
|
952
|
+
});
|
|
953
|
+
} catch {
|
|
954
|
+
if (myGen !== gen) return;
|
|
955
|
+
if (!hadView) patch({ phase: { kind: "error" } });
|
|
956
|
+
else patch({ issue: {
|
|
957
|
+
key,
|
|
958
|
+
reason: opts.messages.quoteRefreshFailed,
|
|
959
|
+
retryable: true
|
|
960
|
+
} });
|
|
961
|
+
} finally {
|
|
962
|
+
release();
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
const mutate = async (fn) => {
|
|
966
|
+
if (holds > 0) return;
|
|
967
|
+
const myGen = gen;
|
|
968
|
+
hold();
|
|
969
|
+
patch({ selectionError: null });
|
|
970
|
+
try {
|
|
971
|
+
await fn(() => myGen !== gen);
|
|
972
|
+
} catch (error) {
|
|
973
|
+
console.error("[checkout] mutation failed:", error);
|
|
974
|
+
} finally {
|
|
975
|
+
release();
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
const updateFromLive = (next) => patch({
|
|
979
|
+
live: next,
|
|
980
|
+
view: stabilize(adaptGuestQuote(next))
|
|
981
|
+
});
|
|
982
|
+
/** Carry the guest's insurance choice onto a quote the SDK just re-priced:
|
|
983
|
+
* a banded re-price renames the tariff rather than withdrawing it, which
|
|
984
|
+
* would silently land the guest back on "keine Versicherung". Free — runs
|
|
985
|
+
* against the list the refresh already returned. */
|
|
986
|
+
const carryInsurance = async (next, wanted) => {
|
|
987
|
+
const reapply = insuranceIdToReapply(adaptGuestQuote(next), wanted);
|
|
988
|
+
if (!reapply) return next;
|
|
989
|
+
const res = await selectQuoteInsurance(next, reapply, getSdk());
|
|
990
|
+
return res.ok ? res.quote : next;
|
|
991
|
+
};
|
|
992
|
+
return {
|
|
993
|
+
changeService(id, delta) {
|
|
994
|
+
mutate(async (stale) => {
|
|
995
|
+
const { live } = raw;
|
|
996
|
+
if (!live) return;
|
|
997
|
+
const chosen = store.getState().chosenInsurance;
|
|
998
|
+
const res = delta > 0 ? await addQuoteService(live, id, getSdk()) : await removeQuoteService(live, id, getSdk());
|
|
999
|
+
if (res.ok) {
|
|
1000
|
+
const next = await carryInsurance(res.quote, chosen);
|
|
1001
|
+
if (stale()) return;
|
|
1002
|
+
patch({
|
|
1003
|
+
live: next,
|
|
1004
|
+
view: stabilize(adaptGuestQuote(next)),
|
|
1005
|
+
counts: {
|
|
1006
|
+
...raw.counts,
|
|
1007
|
+
[id]: Math.max(0, (raw.counts[id] ?? 0) + delta)
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
} else if (res.reason) patch({ selectionError: res.reason });
|
|
1011
|
+
});
|
|
1012
|
+
},
|
|
1013
|
+
selectInsurance(id) {
|
|
1014
|
+
mutate(async (stale) => {
|
|
1015
|
+
const { live } = raw;
|
|
1016
|
+
if (!live) return;
|
|
1017
|
+
const res = await selectQuoteInsurance(live, id, getSdk());
|
|
1018
|
+
if (res.ok && !stale()) updateFromLive(res.quote);
|
|
1019
|
+
});
|
|
1020
|
+
},
|
|
1021
|
+
openInsuranceDoc(url) {
|
|
1022
|
+
const prev = raw.insuranceDocs;
|
|
1023
|
+
if (prev.opened.has(url)) return;
|
|
1024
|
+
patch({ insuranceDocs: {
|
|
1025
|
+
...prev,
|
|
1026
|
+
opened: new Set(prev.opened).add(url)
|
|
1027
|
+
} });
|
|
1028
|
+
},
|
|
1029
|
+
confirmInsuranceDocs(productKey, confirmed) {
|
|
1030
|
+
patch({ insuranceDocs: {
|
|
1031
|
+
...raw.insuranceDocs,
|
|
1032
|
+
confirmedProduct: confirmed ? productKey : null
|
|
1033
|
+
} });
|
|
1034
|
+
},
|
|
1035
|
+
applyVoucher(code) {
|
|
1036
|
+
mutate(async (stale) => {
|
|
1037
|
+
const { stay } = opts;
|
|
1038
|
+
if (!stay) return;
|
|
1039
|
+
patch({ voucherError: null });
|
|
1040
|
+
try {
|
|
1041
|
+
const result = await fetchQuote(rentalId, {
|
|
1042
|
+
start: isoDate(stay.start),
|
|
1043
|
+
end: isoDate(stay.end)
|
|
1044
|
+
}, occupancyOf(stay), getSdk(), code ?? void 0);
|
|
1045
|
+
if (result.status !== "available") {
|
|
1046
|
+
patch({ voucherError: isRateConfigurationFault(result.code) ? opts.messages.rateFault : result.reason || opts.messages.voucherFailed });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
let quote = result.live;
|
|
1050
|
+
const applied = {};
|
|
1051
|
+
const cleared = await clearQuoteServices(quote, getSdk());
|
|
1052
|
+
if (!cleared.ok) throw new Error("clearAdditionalServices failed");
|
|
1053
|
+
quote = cleared.quote;
|
|
1054
|
+
for (const service of result.view.services) {
|
|
1055
|
+
const want = raw.counts[service.id] ?? 0;
|
|
1056
|
+
let current = 0;
|
|
1057
|
+
while (current < want) {
|
|
1058
|
+
const next = await addQuoteService(quote, service.id, getSdk());
|
|
1059
|
+
if (!next.ok) break;
|
|
1060
|
+
quote = next.quote;
|
|
1061
|
+
current++;
|
|
1062
|
+
}
|
|
1063
|
+
applied[service.id] = current;
|
|
1064
|
+
}
|
|
1065
|
+
const afterExtras = adaptGuestQuote(quote);
|
|
1066
|
+
const chosen = raw.view?.policies.find((p) => p.selected)?.id;
|
|
1067
|
+
const fresh = afterExtras.policies.find((p) => p.selected)?.id;
|
|
1068
|
+
if (chosen && chosen !== fresh) {
|
|
1069
|
+
const next = await selectQuotePolicy(quote, chosen, getSdk());
|
|
1070
|
+
if (next.ok) quote = next.quote;
|
|
1071
|
+
}
|
|
1072
|
+
const reapplyInsurance = insuranceIdToReapply(adaptGuestQuote(quote), selectedInsuranceOption(raw.view?.insurance));
|
|
1073
|
+
if (reapplyInsurance) {
|
|
1074
|
+
const next = await selectQuoteInsurance(quote, reapplyInsurance, getSdk());
|
|
1075
|
+
if (next.ok) quote = next.quote;
|
|
1076
|
+
}
|
|
1077
|
+
const nextView = adaptGuestQuote(quote);
|
|
1078
|
+
if (code !== null) {
|
|
1079
|
+
const status = nextView.voucher?.status;
|
|
1080
|
+
if (status === "not_applied") {
|
|
1081
|
+
patch({ voucherError: nextView.voucher?.message || opts.messages.voucherRejected });
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (status !== "applied" && raw.view && JSON.stringify(nextView.card) === JSON.stringify(raw.view.card)) {
|
|
1085
|
+
patch({ voucherError: opts.messages.voucherRejected });
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (stale()) return;
|
|
1090
|
+
patch({
|
|
1091
|
+
live: quote,
|
|
1092
|
+
view: stabilize(nextView),
|
|
1093
|
+
counts: applied,
|
|
1094
|
+
voucherApplied: code
|
|
1095
|
+
});
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
if (stale()) return;
|
|
1098
|
+
console.error("[checkout] voucher failed:", error);
|
|
1099
|
+
patch({ voucherError: opts.messages.voucherFailed });
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
},
|
|
1103
|
+
book(contact) {
|
|
1104
|
+
const s = store.getState();
|
|
1105
|
+
const { live } = raw;
|
|
1106
|
+
if (!live || raw.bookState === "submitting") return;
|
|
1107
|
+
if (s.busy || s.issue || !s.stayKey || s.quotedKey !== s.stayKey) return;
|
|
1108
|
+
if (s.documentsPending) return;
|
|
1109
|
+
patch({ bookState: "submitting" });
|
|
1110
|
+
const submit = async () => {
|
|
1111
|
+
let quote = live;
|
|
1112
|
+
const selected = quote.insurance?.selected;
|
|
1113
|
+
const liveInsuranceId = selected?.kind === "insurance" ? selected.id : null;
|
|
1114
|
+
const shownInsuranceId = s.chosenInsurance?.id ?? null;
|
|
1115
|
+
if (liveInsuranceId !== shownInsuranceId) {
|
|
1116
|
+
const res = await selectQuoteInsurance(quote, shownInsuranceId, getSdk());
|
|
1117
|
+
if (!res.ok) throw new Error("insurance selection on the quote disagrees with the page and could not be reconciled");
|
|
1118
|
+
quote = res.quote;
|
|
1119
|
+
updateFromLive(quote);
|
|
1120
|
+
}
|
|
1121
|
+
return submitBooking(quote, contact, getSdk());
|
|
1122
|
+
};
|
|
1123
|
+
submit().then((result) => {
|
|
1124
|
+
patch({
|
|
1125
|
+
confirmation: result,
|
|
1126
|
+
bookState: "idle"
|
|
1127
|
+
});
|
|
1128
|
+
if (typeof window !== "undefined") window.scrollTo({
|
|
1129
|
+
top: 0,
|
|
1130
|
+
behavior: "smooth"
|
|
1131
|
+
});
|
|
1132
|
+
}).catch((error) => {
|
|
1133
|
+
console.error("[booking] book() failed:", error);
|
|
1134
|
+
patch({ bookState: "error" });
|
|
1135
|
+
});
|
|
1136
|
+
},
|
|
1137
|
+
pay(key, option) {
|
|
1138
|
+
if (!isSubmittableOption(option) || raw.payBusyKey != null) return;
|
|
1139
|
+
patch({
|
|
1140
|
+
payError: false,
|
|
1141
|
+
payBusyKey: key
|
|
1142
|
+
});
|
|
1143
|
+
payWithOption(option, getSdk()).catch(() => {
|
|
1144
|
+
patch({
|
|
1145
|
+
payError: true,
|
|
1146
|
+
payBusyKey: null
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
},
|
|
1150
|
+
clearIssue() {
|
|
1151
|
+
patch({ issue: null });
|
|
1152
|
+
},
|
|
1153
|
+
getState: store.getState,
|
|
1154
|
+
subscribe: store.subscribe,
|
|
1155
|
+
update(next) {
|
|
1156
|
+
const changed = next.stay !== opts.stay || next.chosenPolicy !== opts.chosenPolicy;
|
|
1157
|
+
opts = next;
|
|
1158
|
+
if (!changed) return;
|
|
1159
|
+
store.setState(derive(raw, opts));
|
|
1160
|
+
schedule();
|
|
1161
|
+
},
|
|
1162
|
+
start() {
|
|
1163
|
+
if (started) return;
|
|
1164
|
+
started = true;
|
|
1165
|
+
lastInputs = [];
|
|
1166
|
+
schedule();
|
|
1167
|
+
},
|
|
1168
|
+
dispose() {
|
|
1169
|
+
started = false;
|
|
1170
|
+
if (timer) clearTimeout(timer);
|
|
1171
|
+
timer = null;
|
|
1172
|
+
gen += 1;
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function derive(raw, opts) {
|
|
1177
|
+
const stayKey = opts.stay ? stayKeyOf(opts.stay) : null;
|
|
1178
|
+
const chosenInsurance = selectedInsuranceOption(raw.view?.insurance);
|
|
1179
|
+
const documentsPending = insuranceDocsPending(chosenInsurance, raw.insuranceDocs);
|
|
1180
|
+
const pricePending = !!stayKey && raw.quotedKey !== stayKey && raw.issue?.key !== stayKey && (opts.stay?.adults ?? 0) >= 1;
|
|
1181
|
+
const blocked = !raw.view || !stayKey || raw.quotedKey !== stayKey || raw.busy || !!raw.issue || documentsPending;
|
|
1182
|
+
return {
|
|
1183
|
+
phase: raw.phase,
|
|
1184
|
+
view: raw.view,
|
|
1185
|
+
counts: raw.counts,
|
|
1186
|
+
busy: raw.busy,
|
|
1187
|
+
issue: raw.issue,
|
|
1188
|
+
selectionError: raw.selectionError,
|
|
1189
|
+
quotedKey: raw.quotedKey,
|
|
1190
|
+
stayKey,
|
|
1191
|
+
pricePending,
|
|
1192
|
+
blocked,
|
|
1193
|
+
chosenInsurance,
|
|
1194
|
+
insuranceDocs: raw.insuranceDocs,
|
|
1195
|
+
documentsPending,
|
|
1196
|
+
voucherApplied: raw.voucherApplied,
|
|
1197
|
+
voucherError: raw.voucherError,
|
|
1198
|
+
bookState: raw.bookState,
|
|
1199
|
+
confirmation: raw.confirmation,
|
|
1200
|
+
payBusyKey: raw.payBusyKey,
|
|
1201
|
+
payError: raw.payError
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/checkout/validation.ts
|
|
1206
|
+
function normalizePhone(raw, dial = "49") {
|
|
1207
|
+
const v = raw.replace(/[-./()]+/g, " ").replace(/\s+/g, " ").trim();
|
|
1208
|
+
if (!v) return "";
|
|
1209
|
+
if (v.startsWith("00")) return "+" + v.slice(2);
|
|
1210
|
+
if (v.startsWith("0")) return "+" + dial + " " + v.slice(1);
|
|
1211
|
+
return v;
|
|
1212
|
+
}
|
|
1213
|
+
function phoneError(raw, dial, c) {
|
|
1214
|
+
const compact = normalizePhone(raw, dial).replace(/\s+/g, "");
|
|
1215
|
+
if (!compact) return void 0;
|
|
1216
|
+
if (/^\+[1-9]\d{6,14}$/.test(compact)) return void 0;
|
|
1217
|
+
if (compact.startsWith("+")) return c.phoneInvalid;
|
|
1218
|
+
return c.phoneNeedsDialCode;
|
|
1219
|
+
}
|
|
1220
|
+
function emailError(raw, c) {
|
|
1221
|
+
const v = raw.trim();
|
|
1222
|
+
if (!v) return c.emailRequired;
|
|
1223
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(v)) return c.emailInvalid;
|
|
1224
|
+
}
|
|
1225
|
+
/** Pattern and complaint in ONE entry, so a country can never gain a pattern
|
|
1226
|
+
* without its message — an earlier split into two structures meant a pattern
|
|
1227
|
+
* whose message lookup returned undefined would have ACCEPTED the invalid
|
|
1228
|
+
* PLZ silently. The message reads off the copy so an adopter re-voices it
|
|
1229
|
+
* like every other string; adding a country is a package release (a new copy
|
|
1230
|
+
* key ships with its entry here). */
|
|
1231
|
+
const POSTAL_CODE_FORMATS = {
|
|
1232
|
+
DE: {
|
|
1233
|
+
pattern: /^\d{5}$/,
|
|
1234
|
+
message: (c) => c.postalcodeInvalidDE
|
|
1235
|
+
},
|
|
1236
|
+
AT: {
|
|
1237
|
+
pattern: /^\d{4}$/,
|
|
1238
|
+
message: (c) => c.postalcodeInvalidAT
|
|
1239
|
+
},
|
|
1240
|
+
CH: {
|
|
1241
|
+
pattern: /^\d{4}$/,
|
|
1242
|
+
message: (c) => c.postalcodeInvalidCH
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
function postalcodeError(raw, country, c) {
|
|
1246
|
+
const v = raw.trim();
|
|
1247
|
+
if (!v) return c.postalcodeRequired;
|
|
1248
|
+
const format = POSTAL_CODE_FORMATS[country];
|
|
1249
|
+
if (format && !format.pattern.test(v)) return format.message(c);
|
|
1250
|
+
}
|
|
1251
|
+
function streetWarning(raw, c) {
|
|
1252
|
+
const v = raw.trim();
|
|
1253
|
+
if (!v) return void 0;
|
|
1254
|
+
if (/(?:^|\s)\p{Nd}+\s*\p{L}?$/u.test(v)) return c.streetHouseNumberWarning;
|
|
1255
|
+
}
|
|
1256
|
+
function requiredMessages(c) {
|
|
1257
|
+
return /* @__PURE__ */ new Map([
|
|
1258
|
+
["forename", c.forenameRequired],
|
|
1259
|
+
["surname", c.surnameRequired],
|
|
1260
|
+
["street", c.streetRequired],
|
|
1261
|
+
["city", c.cityRequired]
|
|
1262
|
+
]);
|
|
1263
|
+
}
|
|
1264
|
+
const VALIDATED_FIELDS = [
|
|
1265
|
+
"forename",
|
|
1266
|
+
"surname",
|
|
1267
|
+
"email",
|
|
1268
|
+
"phone",
|
|
1269
|
+
"street",
|
|
1270
|
+
"postalcode",
|
|
1271
|
+
"city"
|
|
1272
|
+
];
|
|
1273
|
+
/** 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. */
|
|
1278
|
+
function fieldError(name, value, country, dial, c) {
|
|
1279
|
+
if (name === "email") return emailError(value, c);
|
|
1280
|
+
if (name === "phone") return phoneError(value, dial, c);
|
|
1281
|
+
if (name === "postalcode") return postalcodeError(value, country, c);
|
|
1282
|
+
const required = requiredMessages(c).get(name);
|
|
1283
|
+
if (required) return value.trim() ? void 0 : required;
|
|
1284
|
+
}
|
|
1285
|
+
function detectCountry(countries) {
|
|
1286
|
+
const known = new Set([...countries.featured, ...countries.all].map(([code]) => code));
|
|
1287
|
+
const candidates = typeof navigator !== "undefined" ? navigator.languages?.length ? navigator.languages : [navigator.language] : [];
|
|
1288
|
+
const regionOf = (tag, maximize) => {
|
|
1289
|
+
try {
|
|
1290
|
+
const locale = new Intl.Locale(tag);
|
|
1291
|
+
return (maximize ? locale.maximize() : locale).region;
|
|
1292
|
+
} catch {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
for (const tag of candidates) {
|
|
1297
|
+
if (!tag || tag.toLowerCase().startsWith("en")) continue;
|
|
1298
|
+
const region = regionOf(tag, true);
|
|
1299
|
+
if (region && known.has(region)) return region;
|
|
1300
|
+
}
|
|
1301
|
+
for (const tag of candidates) {
|
|
1302
|
+
if (!tag || !tag.toLowerCase().startsWith("en")) continue;
|
|
1303
|
+
const region = regionOf(tag, false);
|
|
1304
|
+
if (region && known.has(region)) return region;
|
|
1305
|
+
}
|
|
1306
|
+
return "DE";
|
|
1307
|
+
}
|
|
1308
|
+
//#endregion
|
|
1309
|
+
//#region src/checkout/structure.ts
|
|
1310
|
+
/**
|
|
1311
|
+
* The checkout's STRUCTURAL requirements — what must be on screen, in what
|
|
1312
|
+
* order, regardless of who produced the markup.
|
|
1313
|
+
*
|
|
1314
|
+
* WHY THIS EXISTS. Headless holds the RULES (the § 7 VVG document gate, the
|
|
1315
|
+
* stale-price booking guard) but no markup, so a site that renders its own
|
|
1316
|
+
* checkout UI can silently drop a legal obligation — the § 312j BGB
|
|
1317
|
+
* Button-Lösung wording, the insurance documents listed before the guest's
|
|
1318
|
+
* confirmation, the price stated directly above the order button — and every
|
|
1319
|
+
* unit test still passes. This module is a contract over the RENDERED result,
|
|
1320
|
+
* checked by the platform's publish gate for imported and custom checkouts
|
|
1321
|
+
* alike.
|
|
1322
|
+
*
|
|
1323
|
+
* Two halves:
|
|
1324
|
+
* - `CHECKOUT_PARTS`: the `data-part` vocabulary. Imported components carry
|
|
1325
|
+
* these attributes; a site's own markup must keep them so the gate (and
|
|
1326
|
+
* a site's own CSS) can find the elements.
|
|
1327
|
+
* - `CHECKOUT_STRUCTURE` + `assertCheckoutStructure(root)`: the requirements
|
|
1328
|
+
* and a pure DOM check the smoke suite runs against the rendered checkout
|
|
1329
|
+
* (a browser page or a jsdom document). Every requirement names the legal
|
|
1330
|
+
* basis it exists for, so a failure message tells the reader WHY.
|
|
1331
|
+
*/
|
|
1332
|
+
const CHECKOUT_PARTS = {
|
|
1333
|
+
/** The one place the guest is told the price they are about to owe. */
|
|
1334
|
+
priceTotal: "checkout-price-total",
|
|
1335
|
+
/** The § 312j order button. */
|
|
1336
|
+
submit: "checkout-submit",
|
|
1337
|
+
/** The terms line (AGB + privacy triggers). */
|
|
1338
|
+
terms: "checkout-terms",
|
|
1339
|
+
/** One link per mandatory insurance document (§ 7 VVG). */
|
|
1340
|
+
insuranceDocument: "checkout-insurance-document",
|
|
1341
|
+
/** The guest's receipt confirmation for those documents. */
|
|
1342
|
+
insuranceConfirm: "checkout-insurance-confirm"
|
|
1343
|
+
};
|
|
1344
|
+
/** German "kostenpflichtig bestellen"-class labels the courts have accepted
|
|
1345
|
+
* for § 312j Abs. 3 BGB. The check is on the RENDERED button text, so a site
|
|
1346
|
+
* may re-voice the label — but only within this class. */
|
|
1347
|
+
const BUTTON_SOLUTION_PATTERN = /(zahlungspflichtig|kostenpflichtig)\s+(buchen|bestellen|reservieren)|jetzt\s+kaufen/i;
|
|
1348
|
+
const CHECKOUT_STRUCTURE = {
|
|
1349
|
+
"submit-present": {
|
|
1350
|
+
basis: "§ 312j Abs. 3 BGB (Button-Lösung)",
|
|
1351
|
+
description: "exactly one order button carries data-part=checkout-submit"
|
|
1352
|
+
},
|
|
1353
|
+
"submit-wording": {
|
|
1354
|
+
basis: "§ 312j Abs. 3 BGB (Button-Lösung)",
|
|
1355
|
+
description: "the order button's visible text is a 'zahlungspflichtig buchen'-class label"
|
|
1356
|
+
},
|
|
1357
|
+
"total-before-submit": {
|
|
1358
|
+
basis: "§ 312j Abs. 2 BGB (price stated immediately before the order)",
|
|
1359
|
+
description: "a data-part=checkout-price-total precedes the order button in DOM order"
|
|
1360
|
+
},
|
|
1361
|
+
"terms-before-submit": {
|
|
1362
|
+
basis: "§ 312d BGB / Art. 13 DSGVO (terms + privacy notice before contracting)",
|
|
1363
|
+
description: "a data-part=checkout-terms precedes the order button"
|
|
1364
|
+
},
|
|
1365
|
+
"insurance-docs-before-confirm": {
|
|
1366
|
+
basis: "§ 7 Abs. 1 VVG, § 4 VVG-InfoV (documents before the declaration)",
|
|
1367
|
+
description: "when an insurance confirmation is rendered, at least one insurance document precedes it in DOM order"
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
function parts(root, name) {
|
|
1371
|
+
return Array.from(root.querySelectorAll(`[data-part="${name}"]`));
|
|
1372
|
+
}
|
|
1373
|
+
function precedes(a, b) {
|
|
1374
|
+
return (a.compareDocumentPosition(b) & 4) === 4;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Check a rendered checkout. `root` is `document` in the smoke suite's browser
|
|
1378
|
+
* page, or a jsdom document in tests. Returns every violation found (empty =
|
|
1379
|
+
* compliant). The insurance rule is conditional on purpose: the duty attaches
|
|
1380
|
+
* to concluding an insurance contract, so a checkout that renders no
|
|
1381
|
+
* confirmation control has nothing to gate.
|
|
1382
|
+
*/
|
|
1383
|
+
/** The text a sighted guest sees: descendants hidden by the `hidden` attribute,
|
|
1384
|
+
* `aria-hidden` or an inline display/visibility rule are excluded, so a legal
|
|
1385
|
+
* phrase in a hidden span cannot satisfy the wording rule. Stylesheet-based
|
|
1386
|
+
* hiding needs layout and is the smoke suite's browser run to catch. */
|
|
1387
|
+
function visibleText(el) {
|
|
1388
|
+
let text = "";
|
|
1389
|
+
for (const node of el.childNodes) if (node.nodeType === 3) text += node.textContent ?? "";
|
|
1390
|
+
else if (node.nodeType === 1) {
|
|
1391
|
+
const child = node;
|
|
1392
|
+
if (child.hasAttribute("hidden") || child.getAttribute("aria-hidden") === "true") continue;
|
|
1393
|
+
if (/display\s*:\s*none|visibility\s*:\s*hidden/i.test(child.getAttribute("style") ?? "")) continue;
|
|
1394
|
+
text += ` ${visibleText(child)} `;
|
|
1395
|
+
}
|
|
1396
|
+
return text;
|
|
1397
|
+
}
|
|
1398
|
+
function assertCheckoutStructure(root) {
|
|
1399
|
+
const violations = [];
|
|
1400
|
+
const violate = (requirement, detail) => violations.push({
|
|
1401
|
+
requirement,
|
|
1402
|
+
basis: CHECKOUT_STRUCTURE[requirement].basis,
|
|
1403
|
+
detail
|
|
1404
|
+
});
|
|
1405
|
+
const submits = parts(root, CHECKOUT_PARTS.submit);
|
|
1406
|
+
if (submits.length !== 1) {
|
|
1407
|
+
violate("submit-present", `found ${submits.length} order buttons`);
|
|
1408
|
+
return violations;
|
|
1409
|
+
}
|
|
1410
|
+
const submit = submits[0];
|
|
1411
|
+
const label = visibleText(submit).replace(/\s+/g, " ").trim();
|
|
1412
|
+
if (!BUTTON_SOLUTION_PATTERN.test(label)) violate("submit-wording", `button text is "${label}"`);
|
|
1413
|
+
if (!parts(root, CHECKOUT_PARTS.priceTotal).some((el) => precedes(el, submit))) violate("total-before-submit", "no price total before the order button");
|
|
1414
|
+
if (!parts(root, CHECKOUT_PARTS.terms).some((el) => precedes(el, submit))) violate("terms-before-submit", "no terms line before the order button");
|
|
1415
|
+
const confirms = parts(root, CHECKOUT_PARTS.insuranceConfirm);
|
|
1416
|
+
if (confirms.length > 0) {
|
|
1417
|
+
const docs = parts(root, CHECKOUT_PARTS.insuranceDocument);
|
|
1418
|
+
if (docs.length === 0 || !confirms.every((c) => docs.every((d) => precedes(d, c)))) violate("insurance-docs-before-confirm", `${docs.length} document link(s), not all of them before every confirmation`);
|
|
1419
|
+
}
|
|
1420
|
+
return violations;
|
|
1421
|
+
}
|
|
1422
|
+
//#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 };
|