@voyantjs/bookings-ui 0.80.18 → 0.81.5

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 (33) hide show
  1. package/dist/components/booking-create-dialog.d.ts +0 -18
  2. package/dist/components/booking-create-dialog.d.ts.map +1 -1
  3. package/dist/components/booking-create-dialog.js +126 -221
  4. package/dist/components/booking-create-utils.d.ts +1 -1
  5. package/dist/components/booking-create-utils.d.ts.map +1 -1
  6. package/dist/components/booking-create-utils.js +26 -8
  7. package/dist/components/booking-detail-page.d.ts.map +1 -1
  8. package/dist/components/booking-detail-page.js +3 -1
  9. package/dist/components/booking-payments-summary.d.ts +4 -1
  10. package/dist/components/booking-payments-summary.d.ts.map +1 -1
  11. package/dist/components/booking-payments-summary.js +21 -4
  12. package/dist/components/option-units-stepper-section.d.ts +4 -1
  13. package/dist/components/option-units-stepper-section.d.ts.map +1 -1
  14. package/dist/components/option-units-stepper-section.js +7 -2
  15. package/dist/components/traveler-category-buttons.d.ts +1 -1
  16. package/dist/components/traveler-category-buttons.d.ts.map +1 -1
  17. package/dist/components/traveler-category-buttons.js +3 -3
  18. package/dist/components/travelers-section.d.ts +12 -7
  19. package/dist/components/travelers-section.d.ts.map +1 -1
  20. package/dist/components/travelers-section.js +148 -139
  21. package/dist/i18n/en.d.ts +5 -0
  22. package/dist/i18n/en.d.ts.map +1 -1
  23. package/dist/i18n/en.js +5 -0
  24. package/dist/i18n/messages.d.ts +5 -0
  25. package/dist/i18n/messages.d.ts.map +1 -1
  26. package/dist/i18n/provider.d.ts +10 -0
  27. package/dist/i18n/provider.d.ts.map +1 -1
  28. package/dist/i18n/ro.d.ts +5 -0
  29. package/dist/i18n/ro.d.ts.map +1 -1
  30. package/dist/i18n/ro.js +5 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/package.json +32 -30
@@ -1,22 +1,4 @@
1
1
  import { type BookingRecord } from "@voyantjs/bookings-react";
2
- import { type OptionUnitsStepperUnit } from "./option-units-stepper-section.js";
3
- /**
4
- * Pick the unit for a traveler. Priorities:
5
- * 1. If we have an age (from DOB) and it falls into a unit's
6
- * `[minAge, maxAge]` window, use that unit.
7
- * 2. Otherwise honor an explicit role hint (Child / Infant / Adult
8
- * buttons) by mapping the hint to a representative age and
9
- * matching the age band. This works for products whose units
10
- * encode the band in the code (`child_0_5`, `child_6_12`) instead
11
- * of bare `CHILD`/`INFANT`.
12
- * 3. Fall back to code/name matching for legacy products that don't
13
- * configure min/max ages.
14
- *
15
- * `roleHint` covers the common case where the operator knows the
16
- * traveler is a child but doesn't have the exact DOB. Without it, a
17
- * roleless traveler would silently default to Adult pricing.
18
- */
19
- export declare function pickUnitForAge(units: OptionUnitsStepperUnit[], age: number | null, roleHint?: "adult" | "child" | "infant" | null): OptionUnitsStepperUnit | undefined;
20
2
  export interface BookingCreateDialogProps {
21
3
  open: boolean;
22
4
  onOpenChange: (open: boolean) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"booking-create-dialog.d.ts","sourceRoot":"","sources":["../../src/components/booking-create-dialog.tsx"],"names":[],"mappings":"AASA,OAAO,EAML,KAAK,aAAa,EAGnB,MAAM,0BAA0B,CAAA;AAiCjC,OAAO,EAGL,KAAK,sBAAsB,EAE5B,MAAM,mCAAmC,CAAA;AA6J1C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,sBAAsB,EAAE,EAC/B,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,QAAQ,GAAE,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,IAAW,GACnD,sBAAsB,GAAG,SAAS,CAoCpC;AA4JD,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;IAC5C,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,sBAAsB;IACrC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;IAC5C,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,sEAAsE;IACtE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,gBAAgB,EAChB,aAAa,GACd,EAAE,wBAAwB,2CAsB1B;AAED,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,EACT,gBAAgB,EAChB,aAAa,EACb,OAAc,EACd,QAAQ,GACT,EAAE,sBAAsB,2CAo5BxB"}
1
+ {"version":3,"file":"booking-create-dialog.d.ts","sourceRoot":"","sources":["../../src/components/booking-create-dialog.tsx"],"names":[],"mappings":"AAeA,OAAO,EAKL,KAAK,aAAa,EAInB,MAAM,0BAA0B,CAAA;AAkQjC,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;IAC5C,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,sBAAsB;IACrC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;IAC5C,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,sEAAsE;IACtE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,gBAAgB,EAChB,aAAa,GACd,EAAE,wBAAwB,2CAsB1B;AAED,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,EACT,gBAAgB,EAChB,aAAa,EACb,OAAc,EACd,QAAQ,GACT,EAAE,sBAAsB,2CA89BxB"}
@@ -2,7 +2,8 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useQueries, useQuery } from "@tanstack/react-query";
4
4
  import { getSlotQueryOptions, useSlots, useSlotUnitAvailability, useVoyantAvailabilityContext, } from "@voyantjs/availability-react";
5
- import { useBookingCreateMutation, useBookingTaxPreview, } from "@voyantjs/bookings-react";
5
+ import { resolveBookingDraft, resolveBookingExtraLines, travelersToRows, } from "@voyantjs/bookings/pricing-assignment";
6
+ import { useBookingCreateMutation, useBookingTaxPreview, VoyantApiError, } from "@voyantjs/bookings-react";
6
7
  import { useOrganization, usePerson } from "@voyantjs/crm-react";
7
8
  import { useProductExtras } from "@voyantjs/extras-react";
8
9
  import { useAddresses } from "@voyantjs/identity-react";
@@ -20,7 +21,7 @@ import { emptyPersonPickerValue, PersonPickerSection, } from "./person-picker-se
20
21
  import { PriceBreakdownSection } from "./price-breakdown-section.js";
21
22
  import { ProductPickerSection } from "./product-picker-section.js";
22
23
  import { emptySharedRoomValue, SharedRoomSection, } from "./shared-room-section.js";
23
- import { computeAgeYears, emptyTravelerListValue, TravelersSection, } from "./travelers-section.js";
24
+ import { emptyTravelerListValue, TravelersSection, } from "./travelers-section.js";
24
25
  import { emptyVoucherPickerValue, VoucherPickerSection, } from "./voucher-picker-section.js";
25
26
  function generateBookingNumber() {
26
27
  const now = new Date();
@@ -117,186 +118,6 @@ function stripOptionPrefix(name) {
117
118
  const idx = name.indexOf(" - ");
118
119
  return idx > 0 ? name.slice(idx + 3) : name;
119
120
  }
120
- /**
121
- * Pick the unit for a traveler. Priorities:
122
- * 1. If we have an age (from DOB) and it falls into a unit's
123
- * `[minAge, maxAge]` window, use that unit.
124
- * 2. Otherwise honor an explicit role hint (Child / Infant / Adult
125
- * buttons) by mapping the hint to a representative age and
126
- * matching the age band. This works for products whose units
127
- * encode the band in the code (`child_0_5`, `child_6_12`) instead
128
- * of bare `CHILD`/`INFANT`.
129
- * 3. Fall back to code/name matching for legacy products that don't
130
- * configure min/max ages.
131
- *
132
- * `roleHint` covers the common case where the operator knows the
133
- * traveler is a child but doesn't have the exact DOB. Without it, a
134
- * roleless traveler would silently default to Adult pricing.
135
- */
136
- export function pickUnitForAge(units, age, roleHint = null) {
137
- if (units.length === 0)
138
- return undefined;
139
- const personUnits = units.filter((u) => u.unitType == null || u.unitType === "person");
140
- const pool = personUnits.length > 0 ? personUnits : units;
141
- const sorted = [...pool].sort((a, b) => (a.minAge ?? 0) - (b.minAge ?? 0));
142
- const matchByAge = (target) => sorted.find((u) => (u.minAge == null || target >= u.minAge) && (u.maxAge == null || target <= u.maxAge));
143
- if (age != null) {
144
- const match = matchByAge(age);
145
- if (match)
146
- return match;
147
- }
148
- if (roleHint) {
149
- const HINT_AGE = { adult: 30, child: 8, infant: 1 };
150
- const hintAge = HINT_AGE[roleHint];
151
- // Only consider units with at least one explicit age bound. Without
152
- // this, legacy units with null min/max (just bare ADULT/CHILD codes)
153
- // would all match every hint age and collapse onto the first sorted
154
- // entry (almost always Adult). Code-matching below handles those.
155
- const banded = sorted.filter((u) => u.minAge != null || u.maxAge != null);
156
- const match = banded.find((u) => (u.minAge == null || hintAge >= u.minAge) && (u.maxAge == null || hintAge <= u.maxAge));
157
- if (match)
158
- return match;
159
- }
160
- const findByCode = (code) => sorted.find((u) => (u.unitCode ?? "").toUpperCase() === code) ??
161
- sorted.find((u) => new RegExp(`\\b${code}\\b`, "i").test(u.unitName));
162
- if (roleHint === "child")
163
- return findByCode("CHILD") ?? sorted[0];
164
- if (roleHint === "infant")
165
- return findByCode("INFANT") ?? sorted[0];
166
- return findByCode("ADULT") ?? sorted[sorted.length - 1] ?? sorted[0];
167
- }
168
- /**
169
- * Take the operator-picked per-option quantities (which are tracked
170
- * against each option's primary "Adult" unit by the stepper) plus the
171
- * travelers list, and redistribute both so that:
172
- * - each traveler's `roomUnitId` points at the age-banded unit
173
- * matching their DOB (Adult / Child / Infant / etc.)
174
- * - `quantities` reflects the per-unit counts after redistribution —
175
- * a 3-pax "Standard double" with 2 adults + 1 child becomes
176
- * `{ adultUnit: 2, childUnit: 1 }` instead of `{ adultUnit: 3 }`.
177
- *
178
- * Slots without a configured `dateOfBirth` keep the option's adult
179
- * default so partially-filled bookings still typecheck.
180
- */
181
- /**
182
- * Rebuild stepper quantities from per-traveler unit assignments.
183
- *
184
- * Each traveler's `roomUnitId` is now the operator's explicit choice
185
- * (DOB-pre-picked at attach, overridable via the dynamic category
186
- * buttons), so we count assignments directly and add any per-option
187
- * residual on the adult/primary unit when the stepper qty exceeds the
188
- * number of travelers actually assigned. Unlike the older
189
- * DOB-driven rewrite, this never moves a traveler off their chosen
190
- * unit — operator selection always wins.
191
- */
192
- function redistributeByAge(quantities, travelers, units) {
193
- if (units.length === 0)
194
- return { quantities, travelers };
195
- const unitsByOption = new Map();
196
- for (const unit of units) {
197
- if (!unit.optionId)
198
- continue;
199
- const list = unitsByOption.get(unit.optionId);
200
- if (list)
201
- list.push(unit);
202
- else
203
- unitsByOption.set(unit.optionId, [unit]);
204
- }
205
- const unitToOption = new Map(units.map((u) => [u.optionUnitId, u.optionId]));
206
- const unitById = new Map(units.map((u) => [u.optionUnitId, u]));
207
- // Per-option total from the stepper. This is the count the operator
208
- // committed to when picking rooms.
209
- const totalByOption = new Map();
210
- for (const [unitId, qty] of Object.entries(quantities)) {
211
- if (qty <= 0)
212
- continue;
213
- const optionId = unitToOption.get(unitId);
214
- if (!optionId)
215
- continue;
216
- totalByOption.set(optionId, (totalByOption.get(optionId) ?? 0) + qty);
217
- }
218
- const assignedForDefaulting = new Map();
219
- for (const traveler of travelers) {
220
- if (!traveler.roomUnitId)
221
- continue;
222
- const optionId = unitToOption.get(traveler.roomUnitId);
223
- if (!optionId)
224
- continue;
225
- assignedForDefaulting.set(optionId, (assignedForDefaulting.get(optionId) ?? 0) + 1);
226
- }
227
- const optionDemand = Array.from(totalByOption.entries());
228
- const nextTravelers = travelers.map((traveler) => {
229
- if (traveler.roomUnitId && unitById.has(traveler.roomUnitId))
230
- return traveler;
231
- const optionId = optionDemand.find(([candidate, total]) => (assignedForDefaulting.get(candidate) ?? 0) < total)?.[0] ?? optionDemand[0]?.[0];
232
- if (!optionId)
233
- return traveler;
234
- const age = computeAgeYears(traveler.dateOfBirth);
235
- const roleHint = traveler.role === "adult" || traveler.role === "child" || traveler.role === "infant"
236
- ? traveler.role
237
- : null;
238
- const unit = pickUnitForAge(unitsByOption.get(optionId) ?? [], age, roleHint);
239
- if (!unit)
240
- return traveler;
241
- assignedForDefaulting.set(optionId, (assignedForDefaulting.get(optionId) ?? 0) + 1);
242
- return { ...traveler, roomUnitId: unit.optionUnitId };
243
- });
244
- // Count actual traveler assignments per unit + per option.
245
- const next = {};
246
- const assignedByOption = new Map();
247
- for (const t of nextTravelers) {
248
- if (!t.roomUnitId)
249
- continue;
250
- const optionId = unitToOption.get(t.roomUnitId);
251
- if (!optionId)
252
- continue;
253
- next[t.roomUnitId] = (next[t.roomUnitId] ?? 0) + 1;
254
- assignedByOption.set(optionId, (assignedByOption.get(optionId) ?? 0) + 1);
255
- }
256
- // Residual = operator picked N rooms but only added M travelers; put
257
- // the leftover on the option's adult/primary unit so the price total
258
- // matches the stepper.
259
- for (const [optionId, total] of totalByOption) {
260
- const assigned = assignedByOption.get(optionId) ?? 0;
261
- const residual = Math.max(0, total - assigned);
262
- if (residual === 0)
263
- continue;
264
- const adult = pickUnitForAge(unitsByOption.get(optionId) ?? [], null);
265
- if (!adult)
266
- continue;
267
- next[adult.optionUnitId] = (next[adult.optionUnitId] ?? 0) + residual;
268
- }
269
- return { quantities: next, travelers: nextTravelers };
270
- }
271
- function travelersToRows(value) {
272
- return value.travelers.map((traveler) => {
273
- // Age-derived category (DOB-driven). The `role` field still
274
- // carries the `lead` flag separately for the booking primary; the
275
- // demographic category comes from age, not from a manual select.
276
- const age = computeAgeYears(traveler.dateOfBirth);
277
- const ageCategory = age == null
278
- ? traveler.role === "child" || traveler.role === "infant" || traveler.role === "adult"
279
- ? traveler.role
280
- : null
281
- : age < 2
282
- ? "infant"
283
- : age < 18
284
- ? "child"
285
- : "adult";
286
- return {
287
- personId: traveler.personId,
288
- firstName: traveler.firstName.trim(),
289
- lastName: traveler.lastName.trim(),
290
- email: traveler.email.trim() || null,
291
- phone: traveler.phone.trim() || null,
292
- preferredLanguage: traveler.preferredLanguage.trim() || null,
293
- participantType: "traveler",
294
- travelerCategory: ageCategory,
295
- isPrimary: traveler.role === "lead",
296
- roomUnitId: traveler.roomUnitId,
297
- };
298
- });
299
- }
300
121
  function sameRoomUnits(left, right) {
301
122
  if (left.length !== right.length)
302
123
  return false;
@@ -310,6 +131,37 @@ function sameRoomUnits(left, right) {
310
131
  unit.remaining === other.remaining);
311
132
  });
312
133
  }
134
+ function isPayloadResolverMismatchBody(body) {
135
+ if (typeof body !== "object" || body === null)
136
+ return false;
137
+ const candidate = body;
138
+ return (candidate.code === "payload_resolver_mismatch" &&
139
+ Array.isArray(candidate.mismatches) &&
140
+ candidate.mismatches.every((mismatch) => {
141
+ if (typeof mismatch !== "object" || mismatch === null)
142
+ return false;
143
+ const item = mismatch;
144
+ return ((item.kind === "qty" || item.kind === "missing" || item.kind === "extra") &&
145
+ typeof item.optionUnitId === "string" &&
146
+ typeof item.submittedQuantity === "number" &&
147
+ typeof item.resolvedQuantity === "number");
148
+ }));
149
+ }
150
+ function formatPayloadResolverMismatchError(body, unitLabels, validationMessages) {
151
+ const details = body.mismatches
152
+ .map((mismatch) => {
153
+ const label = unitLabels[mismatch.optionUnitId] ?? mismatch.optionUnitId;
154
+ return formatMessage(validationMessages.payloadResolverMismatchLine, {
155
+ label,
156
+ resolvedQuantity: mismatch.resolvedQuantity,
157
+ submittedQuantity: mismatch.submittedQuantity,
158
+ });
159
+ })
160
+ .join("; ");
161
+ return details
162
+ ? formatMessage(validationMessages.payloadResolverMismatchDetails, { details })
163
+ : validationMessages.payloadResolverMismatchFallback;
164
+ }
313
165
  /**
314
166
  * Operator booking-create dialog. Composes the booking-create picker
315
167
  * sections — product, departure, rooms, person, shared-room, travelers,
@@ -361,6 +213,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
361
213
  // bundle. Defaults on so the operator opts out, not in.
362
214
  const [notifyTraveler, setNotifyTraveler] = React.useState(true);
363
215
  const [error, setError] = React.useState(null);
216
+ const [payloadMismatchUnitIds, setPayloadMismatchUnitIds] = React.useState([]);
364
217
  const { formatDate } = useBookingsUiI18nOrDefault();
365
218
  const messages = useBookingsUiMessagesOrDefault();
366
219
  const availabilityClient = useVoyantAvailabilityContext();
@@ -392,6 +245,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
392
245
  setCreateAsDraft(false);
393
246
  setNotifyTraveler(true);
394
247
  setError(null);
248
+ setPayloadMismatchUnitIds([]);
395
249
  }
396
250
  else if (resolvedDefaultProductId) {
397
251
  setProduct((prev) => {
@@ -415,6 +269,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
415
269
  setRoomUnits([]);
416
270
  setSharedRoom(emptySharedRoomValue);
417
271
  setExtraLines([]);
272
+ setPayloadMismatchUnitIds([]);
418
273
  }, [product.productId, defaultSlotId]);
419
274
  const [slotsFromIso, setSlotsFromIso] = React.useState(() => new Date().toISOString());
420
275
  React.useEffect(() => {
@@ -443,6 +298,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
443
298
  }, [slotsData?.data, slotsFromIso, product.optionId, allOpenSlots]);
444
299
  const selectedSlot = React.useMemo(() => slots.find((slot) => slot.id === slotId) ?? (defaultSlot?.id === slotId ? defaultSlot : null), [slots, slotId, defaultSlot]);
445
300
  const setSelectedSlot = React.useCallback((nextSlotId) => {
301
+ setPayloadMismatchUnitIds([]);
446
302
  const selectedSlot = nextSlotId ? allOpenSlots.find((slot) => slot.id === nextSlotId) : null;
447
303
  if (selectedSlot?.optionId && selectedSlot.optionId !== product.optionId) {
448
304
  setProduct((prev) => ({ ...prev, optionId: selectedSlot.optionId }));
@@ -452,6 +308,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
452
308
  React.useEffect(() => {
453
309
  setRooms(emptyOptionUnitsStepperValue);
454
310
  setRoomUnits([]);
311
+ setPayloadMismatchUnitIds([]);
455
312
  if (!slotId || !product.optionId)
456
313
  return;
457
314
  const selectedDeparture = allOpenSlots.find((slot) => slot.id === slotId) ??
@@ -482,29 +339,25 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
482
339
  const handleRoomUnitsChange = React.useCallback((units) => {
483
340
  setRoomUnits((prev) => (sameRoomUnits(prev, units) ? prev : units));
484
341
  }, []);
485
- // Room choices presented to the traveler row are *options* (e.g.
486
- // "Standard double", "Junior suite upgrade") NOT option-units
487
- // (Adult / Child / Senior). The age-band lives separately on the
488
- // traveler and only affects pricing; both an adult and a child sit
489
- // in the same Standard double room. Each entry's `unitId` is set to
490
- // the option's primary unit so existing `roomUnitId`-keyed plumbing
491
- // (assignment, redistribution) keeps working — `redistributeByAge`
492
- // moves the traveler to the matching age-banded unit at submit.
342
+ // Room choices presented to the traveler row are inventory options
343
+ // (e.g. "Standard double", "Junior suite upgrade"). The age-band
344
+ // lives separately on the traveler as a pricing unit.
493
345
  const roomUnitOptions = React.useMemo(() => {
494
- const units = getTravelerAssignableStepperUnits(roomUnits.length > 0 ? roomUnits : (slotUnitAvailability.data?.data ?? []));
346
+ const sourceUnits = roomUnits.length > 0 ? roomUnits : (slotUnitAvailability.data?.data ?? []);
347
+ const quantityByOption = new Map();
348
+ for (const unit of sourceUnits) {
349
+ const key = unit.optionId ?? unit.optionUnitId;
350
+ quantityByOption.set(key, (quantityByOption.get(key) ?? 0) + (rooms.quantities[unit.optionUnitId] ?? 0));
351
+ }
352
+ const units = sourceUnits.filter((unit) => unit.unitType === "room" || unit.unitType === "vehicle");
495
353
  if (units.length === 0)
496
354
  return [];
497
355
  const optionGroups = new Map();
498
356
  for (const unit of units) {
499
357
  const key = unit.optionId ?? unit.optionUnitId;
500
- // Prefer an ADULT-coded primary; the stepper routes per-option
501
- // qty through the same unit so seat math stays consistent.
502
- const isAdult = (unit.unitCode ?? "").toUpperCase() === "ADULT";
503
358
  const existing = optionGroups.get(key);
504
359
  if (existing) {
505
360
  existing.units.push(unit);
506
- if (isAdult)
507
- existing.primaryUnitId = unit.optionUnitId;
508
361
  }
509
362
  else {
510
363
  optionGroups.set(key, {
@@ -518,15 +371,17 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
518
371
  }
519
372
  return Array.from(optionGroups.values())
520
373
  .filter((group) => {
521
- const totalQty = group.units.reduce((sum, u) => sum + (rooms.quantities[u.optionUnitId] ?? 0), 0);
374
+ const optionKey = group.units[0]?.optionId ?? group.primaryUnitId;
375
+ const totalQty = quantityByOption.get(optionKey) ?? 0;
522
376
  return totalQty > 0;
523
377
  })
524
378
  .map((group) => {
525
- const totalQty = group.units.reduce((sum, u) => sum + (rooms.quantities[u.optionUnitId] ?? 0), 0);
379
+ const optionKey = group.units[0]?.optionId ?? group.primaryUnitId;
380
+ const totalQty = quantityByOption.get(optionKey) ?? 0;
526
381
  const occupancyMax = Math.max(1, ...group.units.map((u) => u.occupancyMax ?? 1));
527
382
  const seats = totalQty * occupancyMax;
528
383
  const optionUnitIds = new Set(group.units.map((u) => u.optionUnitId));
529
- const assigned = travelers.travelers.filter((traveler) => traveler.roomUnitId && optionUnitIds.has(traveler.roomUnitId)).length;
384
+ const assigned = travelers.travelers.filter((traveler) => traveler.inventoryUnitId && optionUnitIds.has(traveler.inventoryUnitId)).length;
530
385
  return {
531
386
  unitId: group.primaryUnitId,
532
387
  unitName: group.optionName,
@@ -540,14 +395,14 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
540
395
  // `roomUnitOptions` but exposes every unit instead of collapsing
541
396
  // to one primary.
542
397
  const roomGroups = React.useMemo(() => {
543
- const travelerUnits = getTravelerAssignableStepperUnits(roomUnits);
544
- if (travelerUnits.length === 0)
398
+ if (roomUnits.length === 0)
545
399
  return [];
546
400
  const groups = new Map();
547
- for (const u of travelerUnits) {
401
+ for (const u of roomUnits) {
548
402
  if (!u.optionId)
549
403
  continue;
550
404
  const groupKey = u.optionId;
405
+ const isInventory = u.unitType === "room" || u.unitType === "vehicle";
551
406
  const isAdultCoded = (u.unitCode ?? "").toUpperCase() === "ADULT";
552
407
  const unit = {
553
408
  unitId: u.optionUnitId,
@@ -563,8 +418,12 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
563
418
  const existing = groups.get(groupKey);
564
419
  if (existing) {
565
420
  existing.units.push(unit);
566
- if (isAdultCoded)
421
+ if (isInventory)
567
422
  existing.primaryUnitId = u.optionUnitId;
423
+ else if (isAdultCoded &&
424
+ !existing.units.some((candidate) => candidate.unitType === "room" || candidate.unitType === "vehicle")) {
425
+ existing.primaryUnitId = u.optionUnitId;
426
+ }
568
427
  }
569
428
  else {
570
429
  groups.set(groupKey, {
@@ -577,12 +436,20 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
577
436
  }
578
437
  return Array.from(groups.values());
579
438
  }, [roomUnits]);
580
- // Apply the same age-banded redistribution we use at submit so the
581
- // live price preview matches what the operator will actually be
582
- // billed. Without this, the breakdown sees only the option's primary
583
- // (Adult) unit qty from the stepper, missing the per-traveler split
584
- // between adult / child / infant tiers.
585
- const displayQuantities = React.useMemo(() => redistributeByAge(rooms.quantities, travelers.travelers, getTravelerAssignableStepperUnits(roomUnits)).quantities, [rooms.quantities, travelers.travelers, roomUnits]);
439
+ // Apply the same draft resolver we use at submit so live pricing
440
+ // and persisted item lines cannot drift. Person-priced options
441
+ // (excursions) derive line quantities from the traveler list;
442
+ // accommodation options preserve operator-picked stepper quantities.
443
+ const displayDraft = React.useMemo(() => resolveBookingDraft({
444
+ quantities: rooms.quantities,
445
+ travelers: travelers.travelers,
446
+ units: roomUnits,
447
+ }), [rooms.quantities, travelers.travelers, roomUnits]);
448
+ const displayQuantities = displayDraft.quantities;
449
+ const displayExtraLines = React.useMemo(() => resolveBookingExtraLines({
450
+ extraLines,
451
+ travelerCount: travelers.travelers.length,
452
+ }), [extraLines, travelers.travelers.length]);
586
453
  // Currency placeholder — used for voucher + payment schedule display.
587
454
  // Consumers hooking in real product data should override this by wrapping
588
455
  // the component or swapping in their own currency-aware hook.
@@ -622,6 +489,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
622
489
  const hasSelectedUnits = React.useMemo(() => Object.values(rooms.quantities).some((qty) => qty > 0), [rooms.quantities]);
623
490
  const handleSubmit = async () => {
624
491
  setError(null);
492
+ setPayloadMismatchUnitIds([]);
625
493
  if (!product.productId) {
626
494
  setError(messages.bookingCreateDialog.validation.selectProduct);
627
495
  return;
@@ -683,17 +551,40 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
683
551
  return;
684
552
  }
685
553
  const paymentSchedules = paymentScheduleToRows(paymentSchedule, pricingCurrency, confirmedSellAmountCents);
686
- // Age-banded redistribution: turn the operator's per-option
687
- // quantities + raw traveler list into per-unit quantities + each
688
- // traveler's matching unit assignment, driven by DOB.
554
+ // Resolve the draft once, then derive every shape the wire
555
+ // format needs from the result. Person-priced options get
556
+ // per-band quantities (1 adult + 1 child + 1 infant, not
557
+ // "3 x Adult"); accommodation options keep operator-picked
558
+ // room quantities. Server gets `clientLineKey` + `travelerKeys`
559
+ // on each line so it can write `booking_item_travelers` rows.
689
560
  const submitUnits = roomUnits.length > 0
690
- ? getTravelerAssignableStepperUnits(roomUnits)
561
+ ? roomUnits
691
562
  : getTravelerAssignableStepperUnits((slotUnitAvailability.data?.data ?? []).map((unit) => ({
692
563
  ...unit,
693
564
  optionId: product.optionId,
694
565
  })));
695
- const redistributed = redistributeByAge(rooms.quantities, travelers.travelers, submitUnits);
696
- const itemLines = itemLinesToRows(redistributed.quantities, submitUnits, pricing);
566
+ const redistributed = resolveBookingDraft({
567
+ quantities: rooms.quantities,
568
+ travelers: travelers.travelers,
569
+ units: submitUnits,
570
+ });
571
+ const travelerKeysByUnitId = Object.fromEntries(Object.entries(redistributed.travelerIndexesByUnitId).map(([unitId, indexes]) => [
572
+ unitId,
573
+ indexes.every((index) => Boolean(redistributed.travelers[index]?.clientTravelerKey))
574
+ ? indexes
575
+ .map((index) => redistributed.travelers[index]?.clientTravelerKey)
576
+ .filter((key) => Boolean(key))
577
+ : [],
578
+ ]));
579
+ const travelerKeys = redistributed.travelers
580
+ .map((traveler) => traveler.clientTravelerKey)
581
+ .filter((key) => Boolean(key));
582
+ const itemLines = itemLinesToRows(redistributed.quantities, submitUnits, pricing, redistributed.travelerIndexesByUnitId, travelerKeysByUnitId);
583
+ const resolvedExtraLines = resolveBookingExtraLines({
584
+ extraLines,
585
+ travelerCount: travelers.travelers.length,
586
+ travelerKeys: travelerKeys.length === redistributed.travelers.length ? travelerKeys : undefined,
587
+ });
697
588
  const travelerRows = travelersToRows({ travelers: redistributed.travelers });
698
589
  const voucherRedemption = voucher.picked && voucher.picked.remainingAmountCents != null
699
590
  ? {
@@ -773,7 +664,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
773
664
  confirmedSellAmountCents,
774
665
  priceOverrideReason: priceOverrideReason || null,
775
666
  itemLines: itemLines.length > 0 ? itemLines : undefined,
776
- extraLines: extraLines.length > 0 ? extraLines : undefined,
667
+ extraLines: resolvedExtraLines.length > 0 ? resolvedExtraLines : undefined,
777
668
  travelers: travelerRows.length > 0 ? travelerRows : undefined,
778
669
  paymentSchedules: paymentSchedules.length > 0 ? paymentSchedules : undefined,
779
670
  voucherRedemption,
@@ -792,15 +683,26 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
792
683
  onCreated?.(booking);
793
684
  }
794
685
  catch (err) {
686
+ if (err instanceof VoyantApiError && isPayloadResolverMismatchBody(err.body)) {
687
+ setPayloadMismatchUnitIds(Array.from(new Set(err.body.mismatches.map((mismatch) => mismatch.optionUnitId))));
688
+ setError(formatPayloadResolverMismatchError(err.body, roomUnitLabels, messages.bookingCreateDialog.validation));
689
+ return;
690
+ }
795
691
  setError(err instanceof Error ? err.message : messages.bookingCreateDialog.validation.createFailed);
796
692
  }
797
693
  };
798
694
  const isSubmitting = createBookingMutation.isPending;
799
- return (_jsxs("div", { className: "grid min-h-0 flex-1 gap-6 lg:grid-cols-12", children: [_jsxs("div", { className: "flex min-h-0 min-w-0 flex-col lg:col-span-8", children: [_jsxs("div", { className: "flex flex-1 flex-col gap-4 overflow-y-auto px-1 pb-2", children: [_jsx(ProductPickerSection, { value: product, onChange: setProduct, enabled: enabled, lockProduct: Boolean(defaultProductId || defaultSlotId), labels: {
695
+ return (_jsxs("div", { className: "grid min-h-0 flex-1 gap-6 lg:grid-cols-12", children: [_jsxs("div", { className: "flex min-h-0 min-w-0 flex-col lg:col-span-8", children: [_jsxs("div", { className: "flex flex-1 flex-col gap-4 overflow-y-auto px-1 pb-2", children: [_jsx(ProductPickerSection, { value: product, onChange: (next) => {
696
+ setPayloadMismatchUnitIds([]);
697
+ setProduct(next);
698
+ }, enabled: enabled, lockProduct: Boolean(defaultProductId || defaultSlotId), labels: {
800
699
  optionNone: messages.bookingCreateDialog.labels.noSpecificOption,
801
- }, showOptionPicker: false }), product.productId ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { children: messages.bookingCreateDialog.fields.departure }), _jsx(AsyncCombobox, { value: slotId, onChange: (v) => setSelectedSlot(v), items: slots, selectedItem: selectedSlot, getKey: (slot) => slot.id, getLabel: (slot) => formatSlotLabel(slot), placeholder: messages.bookingCreateDialog.placeholders.departure, emptyText: messages.bookingCreateDialog.placeholders.departureEmpty, triggerClassName: "w-full", disabled: Boolean(defaultSlotId), clearable: !defaultSlotId })] })) : null, product.productId && slotId ? (_jsx(OptionUnitsStepperSection, { value: rooms, onChange: setRooms, productId: product.productId, slotId: slotId, optionId: product.optionId, enabled: enabled, onUnitsChange: handleRoomUnitsChange, slotHasFiniteCapacity: Boolean(selectedSlot) &&
700
+ }, showOptionPicker: false }), product.productId ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { children: messages.bookingCreateDialog.fields.departure }), _jsx(AsyncCombobox, { value: slotId, onChange: (v) => setSelectedSlot(v), items: slots, selectedItem: selectedSlot, getKey: (slot) => slot.id, getLabel: (slot) => formatSlotLabel(slot), placeholder: messages.bookingCreateDialog.placeholders.departure, emptyText: messages.bookingCreateDialog.placeholders.departureEmpty, triggerClassName: "w-full", disabled: Boolean(defaultSlotId), clearable: !defaultSlotId })] })) : null, product.productId && slotId ? (_jsx(OptionUnitsStepperSection, { value: rooms, onChange: (next) => {
701
+ setPayloadMismatchUnitIds([]);
702
+ setRooms(next);
703
+ }, productId: product.productId, slotId: slotId, optionId: product.optionId, enabled: enabled, onUnitsChange: handleRoomUnitsChange, slotHasFiniteCapacity: Boolean(selectedSlot) &&
802
704
  !selectedSlot?.unlimited &&
803
- typeof selectedSlot?.remainingPax === "number", labels: {
705
+ typeof selectedSlot?.remainingPax === "number", invalidOptionUnitIds: payloadMismatchUnitIds, labels: {
804
706
  heading: messages.bookingCreateDialog.labels.roomsHeading,
805
707
  noOption: messages.bookingCreateDialog.labels.roomsNoOption,
806
708
  noSlot: messages.bookingCreateDialog.labels.roomsNoSlot,
@@ -825,7 +727,10 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
825
727
  noGroups: messages.bookingCreateDialog.labels.sharedRoomNoGroups,
826
728
  createHint: messages.bookingCreateDialog.labels.sharedRoomCreateHint,
827
729
  remove: messages.bookingCreateDialog.labels.sharedRoomRemove,
828
- } })) : null, product.productId && slotId ? (_jsx(TravelersSection, { value: travelers, onChange: setTravelers, roomUnits: roomUnitOptions.length > 0 ? roomUnitOptions : undefined, roomGroups: roomGroups.length > 0 ? roomGroups : undefined, billingPersonId: (person.billTo ?? "person") === "person" ? person.personId : null, labels: {
730
+ } })) : null, product.productId && slotId ? (_jsx(TravelersSection, { value: travelers, onChange: (next) => {
731
+ setPayloadMismatchUnitIds([]);
732
+ setTravelers(next);
733
+ }, roomUnits: roomUnitOptions.length > 0 ? roomUnitOptions : undefined, roomGroups: roomGroups.length > 0 ? roomGroups : undefined, billingPersonId: (person.billTo ?? "person") === "person" ? person.personId : null, labels: {
829
734
  heading: messages.bookingCreateDialog.labels.travelerHeading,
830
735
  addTraveler: messages.bookingCreateDialog.labels.addTraveler,
831
736
  person: messages.bookingCreateDialog.labels.travelerPerson,
@@ -858,7 +763,7 @@ export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId,
858
763
  : messages.bookingCreateDialog.actions.createAwaitingPaymentBooking] })] })] }), _jsxs("div", { className: "flex flex-col gap-4 lg:col-span-4", children: [_jsx(BookingPreviewCard, { productId: product.productId, optionId: product.optionId, slotId: slotId, slotLabel: (() => {
859
764
  const slot = slots.find((s) => s.id === slotId);
860
765
  return slot ? formatSlotLabel(slot) : null;
861
- })(), unitQuantities: displayQuantities, unitLabels: roomUnitLabels, extraLines: extraLines, travelers: travelers.travelers, messages: messages, onPricingChange: setPricing }), product.productId && slotId ? (_jsx(VoucherPickerSection, { value: voucher, onChange: setVoucher, currency: currency, labels: {
766
+ })(), unitQuantities: displayQuantities, unitLabels: roomUnitLabels, extraLines: displayExtraLines, travelers: travelers.travelers, messages: messages, onPricingChange: setPricing }), product.productId && slotId ? (_jsx(VoucherPickerSection, { value: voucher, onChange: setVoucher, currency: currency, labels: {
862
767
  heading: messages.bookingCreateDialog.labels.voucherHeading,
863
768
  codePlaceholder: messages.bookingCreateDialog.labels.voucherCodePlaceholder,
864
769
  apply: messages.bookingCreateDialog.labels.voucherApply,
@@ -42,7 +42,7 @@ export declare function getBookableDepartureSlots<TSlot extends DepartureSlotSea
42
42
  nowIso: string;
43
43
  optionId: string | null;
44
44
  }): TSlot[];
45
- export declare function itemLinesToRows(quantities: Record<string, number>, units: BookingCreateUnitLineRecord[], pricing: BookingCreatePricingRecord | null): BookingCreateItemLineInput[];
45
+ export declare function itemLinesToRows(quantities: Record<string, number>, units: BookingCreateUnitLineRecord[], pricing: BookingCreatePricingRecord | null, travelerIndexesByUnitId?: Record<string, number[]>, travelerKeysByUnitId?: Record<string, string[]>): BookingCreateItemLineInput[];
46
46
  export declare function getSelectedSharedRoomUnitId(quantities: Record<string, number>): string | null;
47
47
  export {};
48
48
  //# sourceMappingURL=booking-create-utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"booking-create-utils.d.ts","sourceRoot":"","sources":["../../src/components/booking-create-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAA;AAC1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAE7D,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAAC,aAAa,EAAE,aAAa,GAAG,MAAM,GAAG,cAAc,CAAC,CAAA;AAEpG,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,8BAA8B;IAC7C,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IACb,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;CAChC;AAED,MAAM,WAAW,0BAA0B;IACzC,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,KAAK,EAAE,8BAA8B,EAAE,CAAA;CACxC;AAED,KAAK,4BAA4B,GAC7B,QAAQ,GACR,OAAO,GACP,MAAM,GACN,SAAS,GACT,SAAS,GACT,OAAO,GACP,IAAI,CAAA;AAER,MAAM,WAAW,yCAAyC;IACxD,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,4BAA4B,CAAA;CACxC;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMhE;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAIjG;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,yBAAyB,GAAG,IAAI,GAAG,SAAS,EACrD,KAAK,EAAE,MAAM,GACZ,OAAO,CAKT;AAED,MAAM,MAAM,oCAAoC,GAAG,OAAO,GAAG,iBAAiB,GAAG,eAAe,CAAA;AAEhG,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAI5E;AAED,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC3E,oCAAoC,CAOtC;AAED,wBAAgB,iCAAiC,CAC/C,KAAK,SAAS,yCAAyC,EACvD,KAAK,EAAE,SAAS,KAAK,EAAE,GAAG,KAAK,EAAE,CAclC;AAED,wBAAgB,yBAAyB,CAAC,KAAK,SAAS,yBAAyB,EAC/E,KAAK,EAAE,SAAS,KAAK,EAAE,EACvB,OAAO,EAAE;IACP,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,GACA,KAAK,EAAE,CAST;AAED,wBAAgB,eAAe,CAC7B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,KAAK,EAAE,2BAA2B,EAAE,EACpC,OAAO,EAAE,0BAA0B,GAAG,IAAI,GACzC,0BAA0B,EAAE,CAyC9B;AAED,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAE7F"}
1
+ {"version":3,"file":"booking-create-utils.d.ts","sourceRoot":"","sources":["../../src/components/booking-create-utils.ts"],"names":[],"mappings":"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"}
@@ -32,20 +32,26 @@ export function validateBillingPersonContact(contact) {
32
32
  return "valid";
33
33
  }
34
34
  export function getTravelerAssignableStepperUnits(units) {
35
- const hasRoomUnitByOption = new Map();
35
+ // "Inventory" units (rooms, vehicles) are containers a traveler is
36
+ // placed into. When an option configures one, person-typed
37
+ // pricing-tier units on the same option are hidden from the
38
+ // stepper since their pricing folds into the container's per-pax
39
+ // accounting at submit time.
40
+ const isInventoryType = (unit) => unit.unitType === "room" || unit.unitType === "vehicle";
41
+ const hasInventoryByOption = new Map();
36
42
  for (const unit of units) {
37
43
  const optionKey = unit.optionId ?? unit.optionUnitId;
38
- if (unit.unitType === "room")
39
- hasRoomUnitByOption.set(optionKey, true);
40
- else if (!hasRoomUnitByOption.has(optionKey))
41
- hasRoomUnitByOption.set(optionKey, false);
44
+ if (isInventoryType(unit))
45
+ hasInventoryByOption.set(optionKey, true);
46
+ else if (!hasInventoryByOption.has(optionKey))
47
+ hasInventoryByOption.set(optionKey, false);
42
48
  }
43
49
  return units.filter((unit) => {
44
- if (unit.unitType === "room")
50
+ if (isInventoryType(unit))
45
51
  return true;
46
52
  if (unit.unitType !== "person")
47
53
  return false;
48
- return !hasRoomUnitByOption.get(unit.optionId ?? unit.optionUnitId);
54
+ return !hasInventoryByOption.get(unit.optionId ?? unit.optionUnitId);
49
55
  });
50
56
  }
51
57
  export function getBookableDepartureSlots(slots, options) {
@@ -59,7 +65,7 @@ export function getBookableDepartureSlots(slots, options) {
59
65
  })
60
66
  .sort((left, right) => left.startsAt.localeCompare(right.startsAt));
61
67
  }
62
- export function itemLinesToRows(quantities, units, pricing) {
68
+ export function itemLinesToRows(quantities, units, pricing, travelerIndexesByUnitId = {}, travelerKeysByUnitId = {}) {
63
69
  const unitsById = new Map(units.map((unit) => [unit.optionUnitId, unit]));
64
70
  const unitNames = new Map(units.map((unit) => [unit.optionUnitId, unit.unitName]));
65
71
  const pricedLines = new Map((pricing?.lines ?? []).map((line) => [line.unitId, line]));
@@ -86,13 +92,25 @@ export function itemLinesToRows(quantities, units, pricing) {
86
92
  }
87
93
  const unitSellAmountCents = pricedLine?.unitAmountCents ??
88
94
  (totalSellAmountCents != null ? Math.floor(totalSellAmountCents / quantity) : null);
95
+ const travelerIndexes = travelerIndexesByUnitId[optionUnitId];
96
+ const travelerKeys = travelerKeysByUnitId[optionUnitId];
97
+ const hasTravelerLinks = Boolean(travelerKeys?.length || travelerIndexes?.length);
89
98
  return {
99
+ // Server uses `clientLineKey` to look up this item after insert
100
+ // and link it to travelers via `booking_item_travelers`. Only
101
+ // stamp when there's an actual traveler mapping to write.
102
+ clientLineKey: hasTravelerLinks ? `unit:${optionUnitId}` : undefined,
90
103
  optionId: unitsById.get(optionUnitId)?.optionId ?? null,
91
104
  optionUnitId,
92
105
  quantity,
93
106
  title: pricedLine?.label ?? unitNames.get(optionUnitId) ?? null,
94
107
  unitSellAmountCents,
95
108
  totalSellAmountCents,
109
+ ...(travelerKeys?.length
110
+ ? { travelerKeys }
111
+ : travelerIndexes?.length
112
+ ? { travelerIndexes }
113
+ : {}),
96
114
  };
97
115
  });
98
116
  }