@voyant-travel/bookings-react 0.201.1 → 0.203.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +8 -8
  2. package/dist/admin/bookings-host.d.ts +1 -2
  3. package/dist/admin/bookings-host.js +2 -3
  4. package/dist/admin/index.d.ts +6 -44
  5. package/dist/admin/index.js +2 -65
  6. package/dist/components/booking-create-utils.d.ts +1 -1
  7. package/dist/components/booking-create-utils.js +5 -17
  8. package/dist/components/booking-dialog.d.ts +4 -19
  9. package/dist/components/booking-dialog.js +3 -12
  10. package/dist/components/booking-list.d.ts +1 -2
  11. package/dist/components/booking-list.js +5 -13
  12. package/dist/components/bookings-page.d.ts +1 -2
  13. package/dist/components/bookings-page.js +2 -2
  14. package/dist/hooks/booking-create-types.d.ts +34 -0
  15. package/dist/hooks/booking-create-types.js +1 -0
  16. package/dist/hooks/index.d.ts +2 -4
  17. package/dist/hooks/index.js +1 -4
  18. package/dist/hooks/use-booking-mutation.d.ts +3 -65
  19. package/dist/hooks/use-booking-mutation.js +1 -10
  20. package/dist/i18n/en-create-list.d.ts +0 -1
  21. package/dist/i18n/en-create-list.js +0 -1
  22. package/dist/i18n/en.d.ts +0 -1
  23. package/dist/i18n/messages-create-list.d.ts +0 -1
  24. package/dist/i18n/provider.d.ts +0 -2
  25. package/dist/i18n/ro-create-list.d.ts +0 -1
  26. package/dist/i18n/ro-create-list.js +0 -1
  27. package/dist/i18n/ro.d.ts +0 -1
  28. package/dist/journey/components/booking-journey-rules.d.ts +0 -22
  29. package/dist/journey/components/booking-journey-rules.js +0 -54
  30. package/dist/journey/components/journey-steps/payment-step.js +1 -1
  31. package/dist/journey/components/journey-steps/shared.d.ts +3 -3
  32. package/dist/journey/components/journey-steps.d.ts +1 -1
  33. package/dist/journey/components/journey-steps.js +1 -1
  34. package/dist/journey/index.d.ts +2 -12
  35. package/dist/journey/index.js +1 -11
  36. package/dist/journey/types.d.ts +3 -208
  37. package/dist/journey/types.js +2 -6
  38. package/dist/storefront/index.d.ts +0 -2
  39. package/dist/storefront/index.js +0 -2
  40. package/dist/ui.d.ts +0 -2
  41. package/dist/ui.js +0 -2
  42. package/package.json +33 -33
  43. package/dist/admin/booking-journey-host.d.ts +0 -23
  44. package/dist/admin/booking-journey-host.js +0 -280
  45. package/dist/admin/journey-billing-duplicate-warning.d.ts +0 -2
  46. package/dist/admin/journey-billing-duplicate-warning.js +0 -26
  47. package/dist/admin/journey-departure-picker.d.ts +0 -6
  48. package/dist/admin/journey-departure-picker.js +0 -91
  49. package/dist/admin/journey-travel-credit-picker.d.ts +0 -2
  50. package/dist/admin/journey-travel-credit-picker.js +0 -72
  51. package/dist/admin/journey-units-picker.d.ts +0 -10
  52. package/dist/admin/journey-units-picker.js +0 -60
  53. package/dist/admin/pages/booking-journey-page.d.ts +0 -11
  54. package/dist/admin/pages/booking-journey-page.js +0 -26
  55. package/dist/admin/pages/booking-new-page.d.ts +0 -15
  56. package/dist/admin/pages/booking-new-page.js +0 -50
  57. package/dist/components/booking-create-page.d.ts +0 -13
  58. package/dist/components/booking-create-page.js +0 -11
  59. package/dist/components/booking-create-sheet.d.ts +0 -34
  60. package/dist/components/booking-create-sheet.js +0 -815
  61. package/dist/hooks/use-booking-convert-mutation.d.ts +0 -80
  62. package/dist/hooks/use-booking-convert-mutation.js +0 -24
  63. package/dist/hooks/use-booking-create-mutation.d.ts +0 -336
  64. package/dist/hooks/use-booking-create-mutation.js +0 -43
  65. package/dist/hooks/use-booking-dual-create-mutation.d.ts +0 -337
  66. package/dist/hooks/use-booking-dual-create-mutation.js +0 -45
  67. package/dist/journey/components/booking-journey.d.ts +0 -2
  68. package/dist/journey/components/booking-journey.js +0 -485
  69. package/dist/storefront/storefront-booking-errors.d.ts +0 -12
  70. package/dist/storefront/storefront-booking-errors.js +0 -50
  71. package/dist/storefront/storefront-booking-journey.d.ts +0 -71
  72. package/dist/storefront/storefront-booking-journey.js +0 -327
  73. package/dist/storefront/storefront-booking-page.d.ts +0 -35
  74. package/dist/storefront/storefront-booking-page.js +0 -215
  75. package/dist/storefront/storefront-checkout-bodies.d.ts +0 -36
  76. package/dist/storefront/storefront-checkout-bodies.js +0 -69
@@ -1,485 +0,0 @@
1
- // agent-quality: file-size exception -- owner: bookings-react; existing UI surface stays co-located until a dedicated split preserves behavior and tests.
2
- "use client";
3
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
- import { useBookingCommit, useBookingDraft, useBookingDraftShape, useBookingHold, useBookingQuote, } from "@voyant-travel/catalog-react/booking-engine";
5
- import { Button } from "@voyant-travel/ui/components/button";
6
- import { useEffect, useMemo, useRef, useState } from "react";
7
- import { useBookingsUiMessagesOrDefault } from "../../i18n/index.js";
8
- import { emptyDraft, totalPax } from "../lib/draft-state.js";
9
- import { findPaidScheduleRowsMissingPaymentDate } from "../lib/payment-schedule.js";
10
- import { JOURNEY_STEP_ORDER, } from "../types.js";
11
- import { buildCommitParty, buildCommitPaymentIntent, canAdvanceFromStep, defaultMinimalShape, isStepVisible, makeHoldSignature, resolveInitialStatus, stackedStepComplete, validationErrorsForStep, warningsForStep, } from "./booking-journey-rules.js";
12
- import { ConfigureStepSkeleton } from "./configure-step-skeleton.js";
13
- import { ContractPreviewDialog } from "./contract-preview-dialog.js";
14
- import { AccommodationStep, AddonsStep, BillingStep, DepartureStep, DocumentsStep, deriveDefaultPhoneCountry, FinalizeControls, OptionsStep, PaymentStep, ReviewStep, TravelersStep, } from "./journey-steps.js";
15
- import { PriceSidePanel } from "./side-panel.js";
16
- import { StackedJourney } from "./stacked-journey.js";
17
- import { StepHeader } from "./step-header.js";
18
- export function BookingJourney(props) {
19
- const messages = useBookingsUiMessagesOrDefault();
20
- // Default phone country: the explicit prop, else the scope locale's region
21
- // (storefronts thread the shopper locale here even when the bookings-ui i18n
22
- // provider isn't mounted). `PhoneField` fills in the i18n locale + GB fallback
23
- // when this is undefined.
24
- const defaultPhoneCountry = deriveDefaultPhoneCountry(props.defaultPhoneCountry, props.scope?.locale);
25
- const surface = props.surface ?? "admin";
26
- // Admin books on a single stacked page (nothing hidden); the storefront
27
- // keeps the guided one-step-at-a-time wizard. Two deliberately separate
28
- // flows — see BookingJourneyProps.layout.
29
- const layout = props.layout ?? (surface === "admin" ? "stacked" : "wizard");
30
- const [draft, setDraft] = useState(() => {
31
- const base = emptyDraft({
32
- module: props.entityModule,
33
- id: props.entityId,
34
- // Empty when storefront — the public engine route resolves
35
- // it server-side from (entityModule, entityId).
36
- sourceKind: props.sourceKind ?? "",
37
- sourceConnectionId: props.sourceConnectionId,
38
- sourceRef: props.sourceRef,
39
- }, { buyerType: props.defaultBuyerType ?? (surface === "admin" ? "B2B" : "B2C") });
40
- // Seed Configure when the caller passed pre-locked state —
41
- // detail page picks departure + pax, booking flow only handles
42
- // travelers + addons + payment.
43
- if (props.initialConfigure) {
44
- const seed = props.initialConfigure;
45
- const seedPax = seed.pax;
46
- base.configure = Object.assign({}, base.configure, seed, {
47
- pax: { ...base.configure.pax, ...(seedPax ?? {}) },
48
- });
49
- }
50
- if (props.initialAccommodation) {
51
- const seed = props.initialAccommodation;
52
- base.accommodation = Object.assign({ rooms: [], travelerAssignments: {} }, base.accommodation ?? {}, seed);
53
- }
54
- return base;
55
- });
56
- const fallbackShape = useMemo(() => props.fallbackShape ?? defaultMinimalShape(), [props.fallbackShape]);
57
- // Server-side draft sync — PUT on each step transition. The shell
58
- // doesn't read from the server in Phase B (drafts are recovery
59
- // surface, not source of truth) but the wire is in place.
60
- const draftSync = useBookingDraft({
61
- surface,
62
- draftId: props.draftId,
63
- enableLoad: false,
64
- });
65
- // Live quote — debounced 250ms.
66
- const quote = useBookingQuote({
67
- surface,
68
- draft,
69
- scope: props.scope,
70
- });
71
- const shape = useBookingDraftShape({
72
- surface,
73
- quote: quote.data,
74
- fallback: fallbackShape,
75
- });
76
- // Step navigation — only show steps the descriptor says are relevant.
77
- const hideConfigure = props.hideConfigure === true;
78
- const steps = useMemo(() => JOURNEY_STEP_ORDER.filter((s) => {
79
- // `hideConfigure` skips the configure phase — now split across the
80
- // Departure + Options steps.
81
- if (hideConfigure && (s === "departure" || s === "options"))
82
- return false;
83
- // Internal notes + document generation are operator-only.
84
- if (s === "documents" && surface !== "admin")
85
- return false;
86
- return isStepVisible(s, shape);
87
- }), [shape, hideConfigure, surface]);
88
- // The stacked admin layout drops the Review block — the side panel shows the
89
- // live summary + Confirm at all times, so a separate review section is
90
- // redundant.
91
- const stackedSteps = useMemo(() => steps.filter((s) => s !== "review"), [steps]);
92
- const [currentStep, setCurrentStep] = useState(() => steps[0] ?? "departure");
93
- const [visited, setVisited] = useState(() => new Set([steps[0] ?? "departure"]));
94
- // If the descriptor changes and removes the current step, reset to
95
- // the first available step. (Edge case: shape goes from
96
- // owned→sourced and the relevant step set narrows.)
97
- useEffect(() => {
98
- if (!steps.includes(currentStep)) {
99
- setCurrentStep(steps[0] ?? "departure");
100
- }
101
- }, [steps, currentStep]);
102
- // PUT the draft to the server to keep the recovery surface fresh
103
- // without saving on every keystroke. Wizard saves on each step
104
- // transition; the stacked admin page has no steps, so it saves on
105
- // each settled quote (a natural, debounced cadence). The mutation
106
- // reads the latest draft + quote from the closure on each fire.
107
- const saveTrigger = layout === "wizard" ? currentStep : (quote.data?.quoteId ?? "");
108
- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fires on save trigger only
109
- useEffect(() => {
110
- draftSync.save.mutate({
111
- draft: { ...draft, quoteId: quote.data?.quoteId },
112
- currentStep,
113
- currentQuoteId: quote.data?.quoteId,
114
- });
115
- }, [saveTrigger]);
116
- // Commit
117
- const commit = useBookingCommit({
118
- surface,
119
- draftId: props.draftId,
120
- onCommitted: props.onCommitted,
121
- });
122
- // Inventory hold — fired when the user advances past Configure
123
- // with a slot + pax picked. Failures are non-blocking (the engine
124
- // re-validates capacity at commit time anyway); we just don't
125
- // want to silently let two shoppers race past Configure with one
126
- // capacity unit between them.
127
- const holdApi = useBookingHold({ surface });
128
- const holdState = useRef({ transition: Promise.resolve() });
129
- const holdSignature = makeHoldSignature(draft, props.entityModule, props.entityId);
130
- // biome-ignore lint/correctness/useExhaustiveDependencies: signature change is the only trigger; refs + closure read latest values -- owner: bookings-react; existing suppression is intentional pending typed cleanup.
131
- useEffect(() => {
132
- // Wizard: don't hold while still on the configure steps. Stacked:
133
- // everything's on one page, so the signature (slot + pax present)
134
- // is the trigger.
135
- if (layout === "wizard" && (currentStep === "departure" || currentStep === "options"))
136
- return;
137
- if (!holdSignature)
138
- return;
139
- if (holdState.current.signature === holdSignature)
140
- return;
141
- // Inquiry mode is the lead-form path — capture the lead without
142
- // burning capacity. The operator follows up before any inventory
143
- // is touched.
144
- if (draft.payment.intent === "inquiry")
145
- return;
146
- holdState.current.signature = holdSignature;
147
- holdState.current.transition = holdState.current.transition.then(async () => {
148
- // A newer selection can arrive while this transition is queued. Skip
149
- // stale work before it reaches inventory.
150
- if (holdState.current.signature !== holdSignature)
151
- return;
152
- const previousToken = holdState.current.holdToken;
153
- if (previousToken) {
154
- holdState.current.holdToken = undefined;
155
- await holdApi
156
- .release({ entityModule: props.entityModule, holdToken: previousToken })
157
- .catch(() => { });
158
- }
159
- // Re-check after the release: a newer transition may now be waiting.
160
- if (holdState.current.signature !== holdSignature)
161
- return;
162
- try {
163
- const result = await holdApi.place({
164
- entityModule: props.entityModule,
165
- entityId: props.entityId,
166
- draftId: props.draftId,
167
- parameters: {
168
- slotId: draft.configure.departureSlotId,
169
- paxCount: totalPax(draft),
170
- productId: props.entityId,
171
- },
172
- });
173
- if (holdState.current.signature === holdSignature) {
174
- holdState.current.holdToken = result.holdToken;
175
- return;
176
- }
177
- // The placement completed after the selection changed. Release it
178
- // before the queued replacement starts so token-wide release cannot
179
- // delete that replacement.
180
- await holdApi
181
- .release({ entityModule: props.entityModule, holdToken: result.holdToken })
182
- .catch(() => { });
183
- }
184
- catch {
185
- // Non-blocking — see comment above.
186
- }
187
- });
188
- }, [holdSignature, currentStep]);
189
- const available = quote.data?.available !== false;
190
- // A failed quote (e.g. the connector adapter 500s) leaves `quote.data` null,
191
- // which makes `available` read as true and lets a stale/absent price slip
192
- // through to Review where Confirm would silently no-op. Treat a quote error
193
- // as a hard block: gate Next/Confirm and surface a recoverable banner + retry.
194
- const hasQuoteError = quote.error != null;
195
- // A settled quote (no thrown error, a real `quoteId`) can still be
196
- // un-committable: the owned accommodation handler returns `available: true`
197
- // with `invalidReason: "rates_missing"` and no pricing when the selected stay
198
- // has no applicable rate plan. Committing that yields a 502 RESERVE_FAILED at
199
- // /book (#2638). Treat any settled quote that is explicitly unavailable or
200
- // carries an `invalidReason` as un-priceable and block contract acceptance /
201
- // Confirm against it.
202
- const quoteUnpriceable = quote.data != null &&
203
- (quote.data.available === false ||
204
- (quote.data.invalidReason != null && quote.data.invalidReason !== ""));
205
- const quoteBlocked = hasQuoteError || quoteUnpriceable;
206
- // The pristine baseline quote is intentionally un-priceable: a product has no
207
- // price until its price driver — a room, or a traveler count — is chosen, and
208
- // the engine reports that empty draft as `no_sell_amount_configured`. Suppress
209
- // the un-priceable banner (and the side-panel error) for exactly that baseline
210
- // so step 1 doesn't scream "can't be priced" before the buyer has picked
211
- // anything. Any other reason (e.g. `rates_missing` on an already-picked stay,
212
- // #2638), or the same code once a driver IS picked (a room that carries no
213
- // price), still surfaces. Commit stays gated on `quoteBlocked` regardless, so
214
- // an unpriced booking can never be submitted.
215
- const priceDriversConfigured = (draft?.configure?.optionSelections?.length ?? 0) > 0 ||
216
- Object.values(draft?.configure?.pax ?? {}).reduce((sum, n) => sum + (n ?? 0), 0) > 0;
217
- const isPristineUnconfigured = quote.data?.invalidReason === "no_sell_amount_configured" && !priceDriversConfigured;
218
- const showQuoteUnpriceable = quoteBlocked && !isPristineUnconfigured;
219
- const quoteReady = Boolean(quote.data?.quoteId);
220
- // Step navigation only hard-blocks on a thrown quote error (a transient fetch
221
- // failure that a retry fixes). An un-priceable quote (e.g. `rates_missing`
222
- // from a preselected room) is *corrected by navigating* — often the room/rate
223
- // editor is a later step — so Next must stay enabled; commit is still gated on
224
- // `quoteBlocked` below so an unpriced booking can never be submitted.
225
- const canAdvance = canAdvanceFromStep(currentStep, draft, shape, available) && !hasQuoteError;
226
- const warnings = warningsForStep(currentStep, draft, shape, messages);
227
- // Stacked layout: there's no "current" step, but the section nav still
228
- // nudges toward the first thing that isn't done yet, and the final
229
- // Confirm is gated until every section passes its check.
230
- const firstIncomplete = useMemo(() => stackedSteps.find((s) => !stackedStepComplete(s, draft, shape, available)) ??
231
- stackedSteps[stackedSteps.length - 1] ??
232
- stackedSteps[0] ??
233
- "departure", [stackedSteps, draft, shape, available]);
234
- const canCommit = useMemo(() => stackedSteps.every((s) => canAdvanceFromStep(s, draft, shape, available)) &&
235
- quoteReady &&
236
- !quoteBlocked, [stackedSteps, draft, shape, available, quoteReady, quoteBlocked]);
237
- const [isAdvanceGuardPending, setIsAdvanceGuardPending] = useState(false);
238
- const [advanceGuardError, setAdvanceGuardError] = useState(null);
239
- // Set when Confirm can't proceed because there's no valid quote — makes the
240
- // click a visible, explained block instead of a silent no-op.
241
- const [confirmError, setConfirmError] = useState(null);
242
- // Clear the "no valid quote" block as soon as a fresh quote settles (via
243
- // retry or an auto re-quote), so the message doesn't linger once resolved.
244
- useEffect(() => {
245
- if (quote.data?.quoteId)
246
- setConfirmError(null);
247
- }, [quote.data?.quoteId]);
248
- const idx = steps.indexOf(currentStep);
249
- const next = steps[idx + 1];
250
- const prev = steps[idx - 1];
251
- const advance = async () => {
252
- if (!next || !canAdvance || isAdvanceGuardPending)
253
- return;
254
- setAdvanceGuardError(null);
255
- if (props.onBeforeStepAdvance) {
256
- setIsAdvanceGuardPending(true);
257
- try {
258
- const guardResult = await props.onBeforeStepAdvance({
259
- currentStep,
260
- nextStep: next,
261
- draft,
262
- pricing: quote.data?.pricing ?? null,
263
- quoteId: quote.data?.quoteId,
264
- surface,
265
- });
266
- if (guardResult &&
267
- typeof guardResult === "object" &&
268
- "draft" in guardResult &&
269
- guardResult.draft) {
270
- setDraft(guardResult.draft);
271
- }
272
- if (guardResult === false) {
273
- setAdvanceGuardError(messages.bookingJourney.validation.completeStepBeforeContinuing);
274
- return;
275
- }
276
- if (guardResult &&
277
- typeof guardResult === "object" &&
278
- "allow" in guardResult &&
279
- guardResult.allow === false) {
280
- setAdvanceGuardError(guardResult.message ?? messages.bookingJourney.validation.completeStepBeforeContinuing);
281
- return;
282
- }
283
- }
284
- catch (error) {
285
- setAdvanceGuardError(error instanceof Error
286
- ? error.message
287
- : messages.bookingJourney.validation.unableToContinue);
288
- return;
289
- }
290
- finally {
291
- setIsAdvanceGuardPending(false);
292
- }
293
- }
294
- setCurrentStep(next);
295
- setVisited((s) => new Set(s).add(next));
296
- };
297
- const goBack = () => {
298
- if (!prev)
299
- return;
300
- setCurrentStep(prev);
301
- };
302
- const jumpTo = (step) => {
303
- if (!visited.has(step) && step !== currentStep)
304
- return;
305
- setCurrentStep(step);
306
- };
307
- const [contractDialogOpen, setContractDialogOpen] = useState(false);
308
- // Tracks the multi-step storefront checkout flow (book →
309
- // checkout-start → redirect). The legacy in-process commit has
310
- // its own `commit.isPending` so the Confirm button merges both
311
- // when deciding whether to show a spinner.
312
- const [isHandlingCheckout, setIsHandlingCheckout] = useState(false);
313
- const contractConfig = props.contract;
314
- const contractVariables = useMemo(() => {
315
- if (!contractConfig)
316
- return {};
317
- return contractConfig.resolveVariables({ draft, pricing: quote.data?.pricing ?? null });
318
- }, [contractConfig, draft, quote.data?.pricing]);
319
- const commitDraft = async () => {
320
- if (!quote.data?.quoteId || quoteBlocked) {
321
- setConfirmError(quoteUnpriceable
322
- ? messages.bookingJourney.validation.pricingUnavailable
323
- : messages.bookingJourney.validation.quoteUnavailable);
324
- return;
325
- }
326
- await commit.mutateAsync({
327
- draft: { ...draft, quoteId: quote.data.quoteId },
328
- quoteId: quote.data.quoteId,
329
- // The owned commit reads the buyer + travelers off `party`, not the
330
- // draft — without this the create rejects with "no billing person/org".
331
- party: buildCommitParty(draft),
332
- initialStatus: resolveInitialStatus(draft),
333
- paymentIntent: buildCommitPaymentIntent(draft),
334
- });
335
- };
336
- const handleAccepted = async (acceptance) => {
337
- if (!props.onContractAccepted) {
338
- await commitDraft();
339
- return;
340
- }
341
- setIsHandlingCheckout(true);
342
- setConfirmError(null);
343
- try {
344
- await props.onContractAccepted(acceptance, {
345
- draft,
346
- pricing: quote.data?.pricing ?? null,
347
- quoteId: quote.data?.quoteId,
348
- });
349
- }
350
- catch (error) {
351
- // The storefront checkout handler drives /book + /checkout/start and can
352
- // fail (e.g. 502 RESERVE_FAILED with reason "rates_missing"). Surface it
353
- // in the checkout UI instead of dropping the customer back on Review with
354
- // only a console log (#2638).
355
- setConfirmError(error instanceof Error && error.message
356
- ? error.message
357
- : messages.bookingJourney.validation.checkoutFailed);
358
- }
359
- finally {
360
- setIsHandlingCheckout(false);
361
- }
362
- };
363
- const onConfirm = async () => {
364
- // No valid quote (never priced, or the last re-quote 500'd) → Confirm must
365
- // NOT be a silent no-op. `useBookingQuote` keeps the prior quote as
366
- // placeholder data on a failed refetch, so a stale `quoteId` can still be
367
- // present while `quote.error` is set; block on `hasQuoteError` too so the
368
- // wizard Review Confirm can never submit against a stale price. Surface a
369
- // recoverable message pointing at the retry banner instead of swallowing.
370
- if (!quote.data?.quoteId || quoteBlocked) {
371
- setConfirmError(quoteUnpriceable
372
- ? messages.bookingJourney.validation.pricingUnavailable
373
- : messages.bookingJourney.validation.quoteUnavailable);
374
- return;
375
- }
376
- if (findPaidScheduleRowsMissingPaymentDate(draft.paymentSchedules) !== null) {
377
- setConfirmError(messages.bookingJourney.validation.paidPaymentDateRequired);
378
- return;
379
- }
380
- setConfirmError(null);
381
- // 1. Contract is wired → open the dialog. Acceptance triggers
382
- // onContractAccepted (the storefront's checkout-start path).
383
- // 2. No contract but onContractAccepted is wired → call it
384
- // directly without an acceptance payload. The storefront uses
385
- // this to drive the /book + /checkout/start handoff so card
386
- // intents redirect to the PSP. Skipping the dialog when no
387
- // template is configured is intentional — the dialog is an
388
- // optional gate, not a required step.
389
- // 3. Neither wired → in-process commit (the operator dashboard's
390
- // legacy path).
391
- if (contractConfig) {
392
- setContractDialogOpen(true);
393
- return;
394
- }
395
- await handleAccepted(null);
396
- };
397
- const onContractAccept = async (acceptance) => {
398
- setContractDialogOpen(false);
399
- await handleAccepted(acceptance);
400
- };
401
- // Bind the picked lead + departure into the billing-extras slot so the
402
- // template can run lead-aware checks (e.g. duplicate-departure warning).
403
- const billingExtrasSlot = props.renderBillingExtras
404
- ? () => props.renderBillingExtras?.({
405
- buyerType: draft.billing.buyerType,
406
- personId: draft.billing.contact.personId,
407
- organizationId: draft.billing.buyerType === "B2B" ? draft.billing.organizationId : undefined,
408
- productId: props.entityId,
409
- departureSlotId: draft.configure.departureSlotId,
410
- departureDate: draft.configure.departureDate,
411
- })
412
- : undefined;
413
- // Recoverable quote-failure banner — rendered in both layouts whenever the
414
- // live quote is erroring. The manual retry re-runs the query (clearing its
415
- // error on success), which re-enables Next/Confirm.
416
- const retryQuote = () => {
417
- setConfirmError(null);
418
- void quote.refetch();
419
- };
420
- const quoteErrorBanner = showQuoteUnpriceable ? (_jsxs("div", { role: "alert", "aria-live": "polite", className: "flex flex-col items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-destructive text-sm", children: [_jsx("span", { children: hasQuoteError
421
- ? messages.bookingJourney.validation.quoteFailed
422
- : messages.bookingJourney.validation.pricingUnavailable }), hasQuoteError ? (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: retryQuote, disabled: quote.isQuoting, children: messages.bookingJourney.validation.retryQuote })) : null] })) : null;
423
- // Renders one step's content. Shared by both layouts — the wizard shows
424
- // exactly one at a time; the stacked page renders them all in sections.
425
- const renderStep = (step) => {
426
- switch (step) {
427
- case "departure":
428
- // First load: the descriptor arrives with the first quote. Show a
429
- // skeleton rather than the generic fallback, which would flash and
430
- // then shift into the real layout.
431
- return !quote.data && quote.isQuoting ? (_jsx(ConfigureStepSkeleton, {})) : (_jsx(DepartureStep, { draft: draft, setDraft: setDraft, shape: shape, productId: props.entityId, renderDeparturePicker: props.renderDeparturePicker }));
432
- case "options":
433
- return (_jsx(OptionsStep, { draft: draft, setDraft: setDraft, shape: shape, productId: props.entityId, renderUnitsPicker: props.renderUnitsPicker }));
434
- case "billing":
435
- return (_jsx(BillingStep, { draft: draft, setDraft: setDraft, shape: shape, defaultPhoneCountry: defaultPhoneCountry, renderLeadContactPicker: props.renderLeadContactPicker, renderExtras: billingExtrasSlot, errors: validationErrorsForStep("billing", draft, messages), warnings: warningsForStep("billing", draft, shape, messages) }));
436
- case "travelers":
437
- return (_jsx(TravelersStep, { draft: draft, setDraft: setDraft, shape: shape, defaultPhoneCountry: defaultPhoneCountry, renderTravelerContactPicker: props.renderTravelerContactPicker, errors: validationErrorsForStep("travelers", draft, messages), warnings: warningsForStep("travelers", draft, shape, messages) }));
438
- case "accommodation":
439
- return _jsx(AccommodationStep, { draft: draft, setDraft: setDraft, shape: shape });
440
- case "addons":
441
- return _jsx(AddonsStep, { draft: draft, setDraft: setDraft, shape: shape });
442
- case "payment":
443
- return (_jsx(PaymentStep, { draft: draft, setDraft: setDraft, shape: shape, capabilities: props.paymentCapabilities ?? {
444
- acceptsCard: false,
445
- acceptsHold: true,
446
- acceptsTicketOnCredit: false,
447
- }, renderProviderStep: props.renderPaymentProviderStep, surface: surface, pricing: quote.data?.pricing ?? null }));
448
- case "documents":
449
- return _jsx(DocumentsStep, { draft: draft, setDraft: setDraft });
450
- case "review":
451
- return (_jsx(ReviewStep, { draft: draft, setDraft: setDraft, isCommitting: commit.isPending || isHandlingCheckout, onConfirm: onConfirm,
452
- // Stacked has no per-step gates, so the Confirm button enforces
453
- // the whole-booking validity itself.
454
- canConfirm: layout === "stacked" ? canCommit : undefined, renderExtras: props.renderReviewExtras, surface: surface, pricing: quote.data?.pricing ?? null, warnings: warningsForStep("review", draft, shape, messages) }));
455
- }
456
- };
457
- if (layout === "stacked") {
458
- return (_jsx(StackedJourney, { className: props.className, steps: stackedSteps, renderStep: renderStep, isStepComplete: (s) => stackedStepComplete(s, draft, shape, available),
459
- // Surface both the in-process commit error and the "no valid quote"
460
- // Confirm block so a failed confirm is never silent.
461
- commitError: commit.error ?? confirmError, onCancel: props.onCancelled, onConfirm: onConfirm, isCommitting: commit.isPending || isHandlingCheckout, canConfirm: canCommit, banner: quoteErrorBanner, sidePanel: _jsx(PriceSidePanel, { pricing: quote.data?.pricing ?? null, isQuoting: quote.isQuoting, invalidReason: quote.data?.invalidReason, entitySummary: props.entitySummary, currentStep: firstIncomplete, steps: stackedSteps, shape: shape, draft: draft, className: props.sidePanelClassName,
462
- // Price override and Travel Credit live with the pricing, not in Payment.
463
- pricingExtras: _jsx(FinalizeControls, { draft: draft, setDraft: setDraft, pricing: quote.data?.pricing ?? null, renderTravelCreditPicker: props.renderTravelCreditPicker }) }), contractDialog: contractConfig ? (_jsx(ContractPreviewDialog, { open: contractDialogOpen, onOpenChange: setContractDialogOpen, previewUrl: contractConfig.previewUrl, acceptLanguage: contractConfig.acceptLanguage, variables: contractVariables, marketingLabel: contractConfig.marketingLabel, termsLabel: contractConfig.termsLabel, onAccept: onContractAccept })) : null }));
464
- }
465
- return (_jsxs("div", { className: props.className, children: [_jsxs("div", { className: "grid grid-cols-1 gap-6 md:grid-cols-8 md:items-start", children: [_jsxs("div", { className: "space-y-6 md:col-span-5", children: [_jsx(StepHeader, { current: currentStep, visited: [...visited], steps: steps, shape: shape, onJumpTo: jumpTo }), quoteErrorBanner, currentStep === "departure" ? (
466
- // First load: the descriptor arrives with the first quote. Show a
467
- // skeleton rather than the generic fallback, which would flash
468
- // and then shift into the real layout.
469
- !quote.data && quote.isQuoting ? (_jsx(ConfigureStepSkeleton, {})) : (_jsx(DepartureStep, { draft: draft, setDraft: setDraft, shape: shape, productId: props.entityId, renderDeparturePicker: props.renderDeparturePicker }))) : null, currentStep === "options" ? (_jsx(OptionsStep, { draft: draft, setDraft: setDraft, shape: shape, productId: props.entityId, renderUnitsPicker: props.renderUnitsPicker })) : null, currentStep === "billing" ? (_jsx(BillingStep, { draft: draft, setDraft: setDraft, shape: shape, defaultPhoneCountry: defaultPhoneCountry, renderLeadContactPicker: props.renderLeadContactPicker, renderExtras: billingExtrasSlot })) : null, currentStep === "travelers" ? (_jsx(TravelersStep, { draft: draft, setDraft: setDraft, shape: shape, defaultPhoneCountry: defaultPhoneCountry, renderTravelerContactPicker: props.renderTravelerContactPicker })) : null, currentStep === "accommodation" ? (_jsx(AccommodationStep, { draft: draft, setDraft: setDraft, shape: shape })) : null, currentStep === "addons" ? (_jsx(AddonsStep, { draft: draft, setDraft: setDraft, shape: shape })) : null, currentStep === "payment" ? (_jsx(PaymentStep, { draft: draft, setDraft: setDraft, shape: shape, capabilities: props.paymentCapabilities ?? {
470
- acceptsCard: false,
471
- acceptsHold: true,
472
- acceptsTicketOnCredit: false,
473
- }, renderProviderStep: props.renderPaymentProviderStep, surface: surface, pricing: quote.data?.pricing ?? null })) : null, currentStep === "review" ? (_jsx(ReviewStep, { draft: draft, setDraft: setDraft, isCommitting: commit.isPending || isHandlingCheckout, onConfirm: onConfirm,
474
- // Disable Confirm when the live quote can't be priced (error or
475
- // rates_missing) so contract acceptance / commit never fires
476
- // against an unpriced booking (#2638).
477
- canConfirm: quoteReady && !quoteBlocked, renderExtras: props.renderReviewExtras, surface: surface, pricing: quote.data?.pricing ?? null })) : null, warnings.length > 0 ? (_jsx("ul", { className: "space-y-1 rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 text-sm dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100", children: warnings.map((w) => (_jsxs("li", { children: ["\u26A0 ", w] }, w))) })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "outline", disabled: isAdvanceGuardPending, onClick: () => {
478
- if (prev)
479
- goBack();
480
- else
481
- props.onCancelled?.();
482
- }, children: messages.bookingJourney.navigation.back }), next ? (_jsx(Button, { type: "button", onClick: () => void advance(), disabled: !canAdvance || isAdvanceGuardPending, className: "ml-auto", children: isAdvanceGuardPending
483
- ? messages.bookingJourney.navigation.checking
484
- : messages.bookingJourney.navigation.next })) : null] }), advanceGuardError ? (_jsx("p", { className: "text-destructive text-sm", role: "alert", "aria-live": "polite", children: advanceGuardError })) : null, confirmError ? (_jsx("p", { className: "text-destructive text-sm", role: "alert", "aria-live": "polite", children: confirmError })) : null, commit.error ? (_jsx("p", { className: "text-destructive text-sm", children: commit.error instanceof Error ? commit.error.message : String(commit.error) })) : null] }), _jsx("aside", { className: "md:sticky md:top-4 md:col-span-3", children: _jsx(PriceSidePanel, { pricing: quote.data?.pricing ?? null, isQuoting: quote.isQuoting, invalidReason: quote.data?.invalidReason, entitySummary: props.entitySummary, currentStep: currentStep, steps: steps, shape: shape, draft: draft, className: props.sidePanelClassName }) })] }), contractConfig ? (_jsx(ContractPreviewDialog, { open: contractDialogOpen, onOpenChange: setContractDialogOpen, previewUrl: contractConfig.previewUrl, acceptLanguage: contractConfig.acceptLanguage, variables: contractVariables, marketingLabel: contractConfig.marketingLabel, termsLabel: contractConfig.termsLabel, onAccept: onContractAccept })) : null] }));
485
- }
@@ -1,12 +0,0 @@
1
- export interface StorefrontBookErrorBody {
2
- error?: unknown;
3
- code?: unknown;
4
- requestId?: unknown;
5
- details?: unknown;
6
- context?: {
7
- upstreamPayload?: {
8
- reason?: unknown;
9
- };
10
- };
11
- }
12
- export declare function buildStorefrontBookFailureMessage(body: StorefrontBookErrorBody, requestId: string | null, fallback: string, requestReferenceTemplate: string): string;
@@ -1,50 +0,0 @@
1
- const SPACE = String.fromCharCode(32);
2
- const PERIOD = String.fromCharCode(46);
3
- const SENTENCE_SEPARATOR = PERIOD + SPACE;
4
- export function buildStorefrontBookFailureMessage(body, requestId, fallback, requestReferenceTemplate) {
5
- const message = firstString(body.error) ?? firstFieldError(body.details);
6
- const reference = firstString(body.requestId) ?? requestId;
7
- const withMessage = message ? appendSentence(fallback, message) : fallback;
8
- return reference
9
- ? appendSentence(withMessage, requestReferenceTemplate.replace("{requestId}", reference))
10
- : withMessage;
11
- }
12
- function appendSentence(base, sentence) {
13
- const trimmedBase = base.trim();
14
- const trimmedSentence = sentence.trim();
15
- if (!trimmedSentence)
16
- return trimmedBase;
17
- const separator = /[.!?]$/.test(trimmedBase) ? SPACE : SENTENCE_SEPARATOR;
18
- const suffix = /[.!?]$/.test(trimmedSentence) ? trimmedSentence : trimmedSentence + PERIOD;
19
- return `${trimmedBase}${separator}${suffix}`;
20
- }
21
- function firstFieldError(details) {
22
- const record = asObject(details);
23
- const fields = asObject(record?.fields) ?? record;
24
- const fieldErrors = asObject(fields?.fieldErrors);
25
- if (!fieldErrors)
26
- return null;
27
- for (const value of Object.values(fieldErrors)) {
28
- const message = firstString(value);
29
- if (message)
30
- return message;
31
- }
32
- return null;
33
- }
34
- function firstString(value) {
35
- if (typeof value === "string")
36
- return value.trim() || null;
37
- if (Array.isArray(value)) {
38
- for (const item of value) {
39
- const message = firstString(item);
40
- if (message)
41
- return message;
42
- }
43
- }
44
- return null;
45
- }
46
- function asObject(value) {
47
- return value && typeof value === "object" && !Array.isArray(value)
48
- ? value
49
- : null;
50
- }
@@ -1,71 +0,0 @@
1
- import { type BookingEntitySummary, type BookingJourneyCheckoutContext, type ContractAcceptanceEvent } from "../journey/index.js";
2
- import { type ContractSourceContext } from "./resolve-contract-variables.js";
3
- export { buildStorefrontBookBody, buildStorefrontCheckoutStartBody, buildStorefrontCommitParty, } from "./storefront-checkout-bodies.js";
4
- export interface StorefrontBookingJourneyMessages {
5
- checkoutFailed: string;
6
- requestReference: string;
7
- reserveFailed: string;
8
- }
9
- export interface StorefrontBookingJourneyScope {
10
- marketId?: string;
11
- locale?: string;
12
- currency?: string;
13
- }
14
- export type StorefrontCheckoutConfirmationKind = "bank_transfer" | "card_pending" | "hold" | "inquiry";
15
- export interface StorefrontBookingJourneyProps {
16
- entityModule: string;
17
- entityId: string;
18
- /**
19
- * Source provenance — optional on the storefront. When absent,
20
- * the public engine route resolves it from
21
- * `(entityModule, entityId)` via the catalog plane's
22
- * sourced-entry lookup. Admin surfaces still pass it explicitly
23
- * via the packaged `<BookingJourneyHost />`
24
- * (`@voyant-travel/bookings-react/admin/booking-journey-host`).
25
- */
26
- sourceKind?: string;
27
- sourceConnectionId?: string;
28
- sourceRef?: string;
29
- draftId: string;
30
- /** Pre-locked configure inputs (departure / sailing / cabin /
31
- * date-range / pax) collected on the detail page. */
32
- initialConfigure: Record<string, unknown>;
33
- /** Pre-locked accommodation slice (room/rate for accommodations). */
34
- initialAccommodation?: Record<string, unknown>;
35
- /** Optional summary of the entity being booked — surfaces in the
36
- * side panel so the customer keeps context while filling out the
37
- * journey. */
38
- entitySummary?: BookingEntitySummary;
39
- /**
40
- * Resolved source provenance for the booked entity — kind /
41
- * connection / ref + supplier. The `/book` route reads it off the
42
- * public content endpoint's `provenance` and passes it here so the
43
- * contract preview's `booking.source` block reflects sourced
44
- * inventory instead of defaulting to owned/blank (voyant#2619).
45
- */
46
- entitySource?: ContractSourceContext;
47
- /**
48
- * Per-product override for the contract template. When unset (the
49
- * default), the storefront resolves the active customer-scope
50
- * template via /v1/public/legal/contracts/templates/default —
51
- * whichever one the operator has marked as the customer default
52
- * in the legal admin. Set this when a specific product/cruise/
53
- * hotel needs a non-default contract.
54
- */
55
- contractTemplateSlug?: string;
56
- /** Optional marketing-opt-in label — when set, an extra checkbox
57
- * is rendered in the contract dialog. */
58
- contractMarketingLabel?: string;
59
- /** Fired after the user accepts the contract (or, when no
60
- * template is configured, when they click Confirm on Review).
61
- * The route handles the actual checkout-start dispatch + redirect
62
- * to Netopia / bank-transfer instructions. */
63
- onContractAccepted?: (acceptance: ContractAcceptanceEvent | null, context: BookingJourneyCheckoutContext) => void | Promise<void>;
64
- messages?: StorefrontBookingJourneyMessages;
65
- scope?: StorefrontBookingJourneyScope;
66
- onNavigateToShop: () => void;
67
- onNavigateToConfirmation: (bookingId: string, kind?: StorefrontCheckoutConfirmationKind) => void;
68
- onRedirectToPayment?: (url: string) => void;
69
- className?: string;
70
- }
71
- export declare function StorefrontBookingJourney({ entityModule, entityId, sourceKind, sourceConnectionId, sourceRef, draftId, initialConfigure, initialAccommodation, entitySummary, entitySource, contractTemplateSlug, contractMarketingLabel, onContractAccepted, messages, scope, onNavigateToShop, onNavigateToConfirmation, onRedirectToPayment, className, }: StorefrontBookingJourneyProps): React.ReactElement;