@voyant-travel/bookings-react 0.158.0 → 0.160.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.
@@ -125,7 +125,7 @@ export function BookingJourney(props) {
125
125
  // want to silently let two shoppers race past Configure with one
126
126
  // capacity unit between them.
127
127
  const holdApi = useBookingHold({ surface });
128
- const holdState = useRef({});
128
+ const holdState = useRef({ transition: Promise.resolve() });
129
129
  const holdSignature = makeHoldSignature(draft, props.entityModule, props.entityId);
130
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
131
  useEffect(() => {
@@ -143,30 +143,48 @@ export function BookingJourney(props) {
143
143
  // is touched.
144
144
  if (draft.payment.intent === "inquiry")
145
145
  return;
146
- const previousToken = holdState.current.holdToken;
147
- holdState.current = { signature: holdSignature };
148
- void holdApi
149
- .place({
150
- entityModule: props.entityModule,
151
- entityId: props.entityId,
152
- draftId: props.draftId,
153
- parameters: {
154
- slotId: draft.configure.departureSlotId,
155
- paxCount: totalPax(draft),
156
- productId: props.entityId,
157
- },
158
- })
159
- .then((result) => {
160
- holdState.current = { holdToken: result.holdToken, signature: holdSignature };
161
- })
162
- .catch(() => {
163
- // Non-blocking see comment above.
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
+ }
164
187
  });
165
- if (previousToken) {
166
- void holdApi
167
- .release({ entityModule: props.entityModule, holdToken: previousToken })
168
- .catch(() => { });
169
- }
170
188
  }, [holdSignature, currentStep]);
171
189
  const available = quote.data?.available !== false;
172
190
  // A failed quote (e.g. the connector adapter 500s) leaves `quote.data` null,
@@ -1,5 +1,6 @@
1
- import { type BookingEntitySummary, type BookingJourneyCheckoutContext, type ContractAcceptanceEvent, type Draft } from "../journey/index.js";
1
+ import { type BookingEntitySummary, type BookingJourneyCheckoutContext, type ContractAcceptanceEvent } from "../journey/index.js";
2
2
  import { type ContractSourceContext } from "./resolve-contract-variables.js";
3
+ export { buildStorefrontBookBody, buildStorefrontCheckoutStartBody, buildStorefrontCommitParty, } from "./storefront-checkout-bodies.js";
3
4
  export interface StorefrontBookingJourneyMessages {
4
5
  checkoutFailed: string;
5
6
  requestReference: string;
@@ -68,4 +69,3 @@ export interface StorefrontBookingJourneyProps {
68
69
  className?: string;
69
70
  }
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;
71
- export declare function buildStorefrontCommitParty(draft: Draft): Record<string, unknown>;
@@ -17,6 +17,8 @@ import { useVoyantReactContext } from "@voyant-travel/react";
17
17
  import { BookingJourney, } from "../journey/index.js";
18
18
  import { resolveContractVariables, } from "./resolve-contract-variables.js";
19
19
  import { buildStorefrontBookFailureMessage, } from "./storefront-booking-errors.js";
20
+ import { buildStorefrontBookBody, buildStorefrontCheckoutStartBody, } from "./storefront-checkout-bodies.js";
21
+ export { buildStorefrontBookBody, buildStorefrontCheckoutStartBody, buildStorefrontCommitParty, } from "./storefront-checkout-bodies.js";
20
22
  /**
21
23
  * Marker for checkout failures we've already turned into a localized,
22
24
  * customer-facing message. Native errors (a `fetch` network drop, a
@@ -72,10 +74,6 @@ export function StorefrontBookingJourney({ entityModule, entityId, sourceKind, s
72
74
  //
73
75
  const defaultCheckoutHandler = async (acceptance, context) => {
74
76
  try {
75
- const intent = checkoutIntentFromDraft(context.draft.payment.intent);
76
- const contact = context.draft.billing?.contact;
77
- const payerName = [contact?.firstName, contact?.lastName].filter(Boolean).join(" ").trim();
78
- const idempotencyKey = `bj-${draftId}-${acceptance?.acceptedAt ?? "noaccept"}`;
79
77
  // Step 1 — book the entity. Send the live scoped quote id explicitly
80
78
  // (the server prefers `quoteId` over resolving the draft's stored
81
79
  // `currentQuoteId`), so a market/currency change made mid-journey books
@@ -85,13 +83,12 @@ export function StorefrontBookingJourney({ entityModule, entityId, sourceKind, s
85
83
  method: "POST",
86
84
  credentials: "include",
87
85
  headers: { "content-type": "application/json" },
88
- body: JSON.stringify({
86
+ body: JSON.stringify(buildStorefrontBookBody({
89
87
  draftId,
90
88
  quoteId: context.quoteId,
91
- party: buildStorefrontCommitParty(context.draft),
92
- paymentIntent: { type: "hold" },
93
- idempotencyKey,
94
- }),
89
+ draft: context.draft,
90
+ acceptedAt: acceptance?.acceptedAt,
91
+ })),
95
92
  });
96
93
  if (!bookRes.ok) {
97
94
  const errBody = (await bookRes.json().catch(() => ({})));
@@ -124,25 +121,12 @@ export function StorefrontBookingJourney({ entityModule, entityId, sourceKind, s
124
121
  method: "POST",
125
122
  credentials: "include",
126
123
  headers: { "content-type": "application/json" },
127
- body: JSON.stringify({
124
+ body: JSON.stringify(buildStorefrontCheckoutStartBody({
128
125
  bookingId,
129
- paymentIntent: intent,
130
- ...(acceptance
131
- ? {
132
- contractAcceptance: {
133
- templateId: acceptance.templateId,
134
- templateSlug: acceptance.templateSlug,
135
- acceptedTerms: true,
136
- acceptedMarketing: acceptance.acceptedMarketing,
137
- acceptedAt: acceptance.acceptedAt,
138
- renderedHtml: acceptance.renderedHtml,
139
- },
140
- }
141
- : {}),
142
- ...(contact?.email ? { payerEmail: contact.email } : {}),
143
- ...(payerName ? { payerName } : {}),
126
+ draft: context.draft,
127
+ acceptance,
144
128
  returnOrigin: window.location.origin,
145
- }),
129
+ })),
146
130
  });
147
131
  const json = (await startRes.json());
148
132
  if ("error" in json) {
@@ -241,43 +225,6 @@ export function StorefrontBookingJourney({ entityModule, entityId, sourceKind, s
241
225
  acceptsInquiry: true,
242
226
  }, className: className, ...slots }));
243
227
  }
244
- function checkoutIntentFromDraft(intent) {
245
- if (intent === "card" || intent === "bank_transfer" || intent === "inquiry")
246
- return intent;
247
- return "hold";
248
- }
249
- export function buildStorefrontCommitParty(draft) {
250
- const contact = draft.billing.contact;
251
- const personId = draft.billing.buyerType === "B2C" ? contact.personId : undefined;
252
- const organizationId = draft.billing.buyerType === "B2B" ? draft.billing.organizationId : undefined;
253
- return {
254
- personId,
255
- organizationId,
256
- billing: {
257
- personId,
258
- organizationId,
259
- contact: {
260
- firstName: contact.firstName,
261
- lastName: contact.lastName,
262
- email: contact.email,
263
- phone: contact.phone,
264
- },
265
- },
266
- travelerParty: {
267
- travelers: draft.travelers.map((traveler) => ({
268
- personId: traveler.personId,
269
- firstName: traveler.firstName,
270
- lastName: traveler.lastName,
271
- email: traveler.email,
272
- phone: traveler.phone,
273
- dateOfBirth: traveler.dateOfBirth,
274
- band: traveler.band,
275
- documents: traveler.documents,
276
- isPrimary: traveler.isPrimary,
277
- })),
278
- },
279
- };
280
- }
281
228
  /**
282
229
  * Resolve which contract template to preview at the Review step.
283
230
  *
@@ -0,0 +1,36 @@
1
+ import type { ContractAcceptanceEvent, Draft } from "../journey/index.js";
2
+ export declare function buildStorefrontBookBody(input: {
3
+ draftId: string;
4
+ quoteId?: string;
5
+ draft: Draft;
6
+ acceptedAt?: string;
7
+ }): {
8
+ draftId: string;
9
+ quoteId: string | undefined;
10
+ party: Record<string, unknown>;
11
+ paymentIntent: {
12
+ type: "hold";
13
+ };
14
+ idempotencyKey: string;
15
+ };
16
+ export declare function buildStorefrontCheckoutStartBody(input: {
17
+ bookingId: string;
18
+ draft: Draft;
19
+ acceptance: ContractAcceptanceEvent | null;
20
+ returnOrigin: string;
21
+ }): {
22
+ returnOrigin: string;
23
+ payerName?: string | undefined;
24
+ payerEmail?: string | undefined;
25
+ contractAcceptance?: {
26
+ templateId: string;
27
+ templateSlug: string;
28
+ acceptedTerms: true;
29
+ acceptedMarketing: boolean;
30
+ acceptedAt: string;
31
+ renderedHtml: string;
32
+ } | undefined;
33
+ bookingId: string;
34
+ paymentIntent: "hold" | "card" | "bank_transfer" | "inquiry";
35
+ };
36
+ export declare function buildStorefrontCommitParty(draft: Draft): Record<string, unknown>;
@@ -0,0 +1,69 @@
1
+ export function buildStorefrontBookBody(input) {
2
+ return {
3
+ draftId: input.draftId,
4
+ quoteId: input.quoteId,
5
+ party: buildStorefrontCommitParty(input.draft),
6
+ paymentIntent: { type: "hold" },
7
+ idempotencyKey: `bj-${input.draftId}-${input.acceptedAt ?? "noaccept"}`,
8
+ };
9
+ }
10
+ export function buildStorefrontCheckoutStartBody(input) {
11
+ const contact = input.draft.billing?.contact;
12
+ const payerName = [contact?.firstName, contact?.lastName].filter(Boolean).join(" ").trim();
13
+ return {
14
+ bookingId: input.bookingId,
15
+ paymentIntent: checkoutIntentFromDraft(input.draft.payment.intent),
16
+ ...(input.acceptance
17
+ ? {
18
+ contractAcceptance: {
19
+ templateId: input.acceptance.templateId,
20
+ templateSlug: input.acceptance.templateSlug,
21
+ acceptedTerms: true,
22
+ acceptedMarketing: input.acceptance.acceptedMarketing,
23
+ acceptedAt: input.acceptance.acceptedAt,
24
+ renderedHtml: input.acceptance.renderedHtml,
25
+ },
26
+ }
27
+ : {}),
28
+ ...(contact?.email ? { payerEmail: contact.email } : {}),
29
+ ...(payerName ? { payerName } : {}),
30
+ returnOrigin: input.returnOrigin,
31
+ };
32
+ }
33
+ function checkoutIntentFromDraft(intent) {
34
+ if (intent === "card" || intent === "bank_transfer" || intent === "inquiry")
35
+ return intent;
36
+ return "hold";
37
+ }
38
+ export function buildStorefrontCommitParty(draft) {
39
+ const contact = draft.billing.contact;
40
+ const personId = draft.billing.buyerType === "B2C" ? contact.personId : undefined;
41
+ const organizationId = draft.billing.buyerType === "B2B" ? draft.billing.organizationId : undefined;
42
+ return {
43
+ personId,
44
+ organizationId,
45
+ billing: {
46
+ personId,
47
+ organizationId,
48
+ contact: {
49
+ firstName: contact.firstName,
50
+ lastName: contact.lastName,
51
+ email: contact.email,
52
+ phone: contact.phone,
53
+ },
54
+ },
55
+ travelerParty: {
56
+ travelers: draft.travelers.map((traveler) => ({
57
+ personId: traveler.personId,
58
+ firstName: traveler.firstName,
59
+ lastName: traveler.lastName,
60
+ email: traveler.email,
61
+ phone: traveler.phone,
62
+ dateOfBirth: traveler.dateOfBirth,
63
+ band: traveler.band,
64
+ documents: traveler.documents,
65
+ isPrimary: traveler.isPrimary,
66
+ })),
67
+ },
68
+ };
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/bookings-react",
3
- "version": "0.158.0",
3
+ "version": "0.160.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -145,29 +145,29 @@
145
145
  "peerDependencies": {
146
146
  "@tanstack/react-query": "^5.0.0",
147
147
  "@tanstack/react-table": "^8.0.0",
148
- "lucide-react": "^1.23.0",
148
+ "lucide-react": "^0.475.0 || ^1.0.0",
149
149
  "react": "^19.0.0",
150
150
  "react-dom": "^19.0.0",
151
151
  "react-hook-form": "^7.80.0",
152
152
  "zod": "^4.0.0",
153
- "@voyant-travel/accommodations": "^0.118.0",
154
- "@voyant-travel/admin": "^0.124.0",
155
- "@voyant-travel/bookings": "^0.158.0",
156
- "@voyant-travel/catalog": "^0.156.0",
157
- "@voyant-travel/catalog-react": "^0.156.0",
158
- "@voyant-travel/distribution-react": "^0.148.0",
159
- "@voyant-travel/relationships-react": "^0.158.0",
160
- "@voyant-travel/finance": "^0.158.0",
161
- "@voyant-travel/finance-react": "^0.158.0",
162
- "@voyant-travel/identity-react": "^0.158.0",
163
- "@voyant-travel/legal-react": "^0.158.0",
164
- "@voyant-travel/commerce-react": "^0.40.0",
165
- "@voyant-travel/cruises": "^0.157.0",
166
- "@voyant-travel/inventory-react": "^0.40.0",
167
- "@voyant-travel/inventory": "^0.10.3",
168
- "@voyant-travel/storefront-react": "^0.160.0",
169
- "@voyant-travel/ui": "^0.109.1",
170
- "@voyant-travel/operations-react": "^0.39.0"
153
+ "@voyant-travel/accommodations": "^0.120.0",
154
+ "@voyant-travel/admin": "^0.125.0",
155
+ "@voyant-travel/bookings": "^0.160.0",
156
+ "@voyant-travel/catalog": "^0.158.0",
157
+ "@voyant-travel/catalog-react": "^0.158.0",
158
+ "@voyant-travel/distribution-react": "^0.150.0",
159
+ "@voyant-travel/relationships-react": "^0.160.0",
160
+ "@voyant-travel/finance": "^0.160.0",
161
+ "@voyant-travel/finance-react": "^0.160.0",
162
+ "@voyant-travel/identity-react": "^0.160.0",
163
+ "@voyant-travel/legal-react": "^0.160.0",
164
+ "@voyant-travel/commerce-react": "^0.42.0",
165
+ "@voyant-travel/cruises": "^0.159.0",
166
+ "@voyant-travel/inventory-react": "^0.42.0",
167
+ "@voyant-travel/inventory": "^0.11.0",
168
+ "@voyant-travel/storefront-react": "^0.162.0",
169
+ "@voyant-travel/ui": "^0.109.2",
170
+ "@voyant-travel/operations-react": "^0.41.0"
171
171
  },
172
172
  "peerDependenciesMeta": {
173
173
  "@tanstack/react-table": {
@@ -233,7 +233,7 @@
233
233
  },
234
234
  "dependencies": {
235
235
  "sonner": "^2.0.7",
236
- "@voyant-travel/catalog-contracts": "^0.111.0",
236
+ "@voyant-travel/catalog-contracts": "^0.111.1",
237
237
  "@voyant-travel/i18n": "^0.111.1",
238
238
  "@voyant-travel/react": "^0.104.2",
239
239
  "@voyant-travel/types": "^0.109.2"
@@ -251,26 +251,26 @@
251
251
  "typescript": "^6.0.3",
252
252
  "vitest": "^4.1.9",
253
253
  "zod": "^4.4.3",
254
- "@voyant-travel/accommodations": "^0.118.0",
255
- "@voyant-travel/admin": "^0.124.0",
256
- "@voyant-travel/bookings": "^0.158.0",
257
- "@voyant-travel/catalog": "^0.156.0",
258
- "@voyant-travel/catalog-react": "^0.156.0",
259
- "@voyant-travel/distribution-react": "^0.148.0",
260
- "@voyant-travel/relationships-react": "^0.158.0",
261
- "@voyant-travel/finance": "^0.158.0",
262
- "@voyant-travel/finance-react": "^0.158.0",
263
- "@voyant-travel/identity-react": "^0.158.0",
264
- "@voyant-travel/legal-react": "^0.158.0",
265
- "@voyant-travel/commerce-react": "^0.40.0",
266
- "@voyant-travel/cruises": "^0.157.0",
267
- "@voyant-travel/inventory-react": "^0.40.0",
268
- "@voyant-travel/inventory": "^0.10.3",
254
+ "@voyant-travel/accommodations": "^0.120.0",
255
+ "@voyant-travel/admin": "^0.125.0",
256
+ "@voyant-travel/bookings": "^0.160.0",
257
+ "@voyant-travel/catalog": "^0.158.0",
258
+ "@voyant-travel/catalog-react": "^0.158.0",
259
+ "@voyant-travel/distribution-react": "^0.150.0",
260
+ "@voyant-travel/relationships-react": "^0.160.0",
261
+ "@voyant-travel/finance": "^0.160.0",
262
+ "@voyant-travel/finance-react": "^0.160.0",
263
+ "@voyant-travel/identity-react": "^0.160.0",
264
+ "@voyant-travel/legal-react": "^0.160.0",
265
+ "@voyant-travel/commerce-react": "^0.42.0",
266
+ "@voyant-travel/cruises": "^0.159.0",
267
+ "@voyant-travel/inventory-react": "^0.42.0",
268
+ "@voyant-travel/inventory": "^0.11.0",
269
269
  "@voyant-travel/react": "^0.104.2",
270
- "@voyant-travel/storefront-react": "^0.160.0",
271
- "@voyant-travel/ui": "^0.109.1",
270
+ "@voyant-travel/storefront-react": "^0.162.0",
271
+ "@voyant-travel/ui": "^0.109.2",
272
272
  "@voyant-travel/voyant-typescript-config": "^0.1.0",
273
- "@voyant-travel/operations-react": "^0.39.0"
273
+ "@voyant-travel/operations-react": "^0.41.0"
274
274
  },
275
275
  "files": [
276
276
  "dist",