@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/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 };