@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
@@ -9,9 +9,16 @@ import * as React from "react";
9
9
  import { useBookingsUiMessagesOrDefault } from "../i18n/provider.js";
10
10
  import { getDynamicTravelerCategoryButtonState, getStaticTravelerCategoryButtonState, } from "./traveler-category-buttons.js";
11
11
  export const emptyTravelerListValue = { travelers: [] };
12
+ function createClientTravelerKey() {
13
+ const random = typeof globalThis.crypto?.randomUUID === "function"
14
+ ? globalThis.crypto.randomUUID().replace(/-/g, "")
15
+ : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
16
+ return `trav:${random}`;
17
+ }
12
18
  /** Factory for a blank row — `role` defaults to `adult` unless the list is empty. */
13
19
  export function createBlankTraveler(role = "adult") {
14
20
  return {
21
+ clientTravelerKey: createClientTravelerKey(),
15
22
  personId: null,
16
23
  firstName: "",
17
24
  lastName: "",
@@ -20,26 +27,17 @@ export function createBlankTraveler(role = "adult") {
20
27
  preferredLanguage: "",
21
28
  role,
22
29
  dateOfBirth: null,
23
- roomUnitId: null,
30
+ pricingUnitId: null,
31
+ inventoryUnitId: null,
32
+ pricingUnitSource: "auto",
33
+ inventoryUnitSource: "auto",
24
34
  };
25
35
  }
26
- /**
27
- * Compute integer age in full years from an ISO date-of-birth string.
28
- * Returns null when the DOB is missing or unparseable.
29
- */
30
- export function computeAgeYears(dob, now = new Date()) {
31
- if (!dob)
32
- return null;
33
- const birth = new Date(dob);
34
- if (Number.isNaN(birth.getTime()))
35
- return null;
36
- let age = now.getFullYear() - birth.getFullYear();
37
- const beforeBirthday = now.getMonth() < birth.getMonth() ||
38
- (now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate());
39
- if (beforeBirthday)
40
- age -= 1;
41
- return age >= 0 ? age : null;
42
- }
36
+ // Re-export `computeAgeYears` from the canonical assignment module so
37
+ // existing consumers of `travelers-section`'s public surface keep
38
+ // working. The implementation lives in `@voyantjs/bookings/pricing-assignment`.
39
+ export { computeAgeYears } from "@voyantjs/bookings/pricing-assignment";
40
+ import { computeAgeYears as _computeAgeYears, matchUnitByDob as matchAssignmentUnitByDob, matchUnitByRoleHint as matchAssignmentUnitByRoleHint, } from "@voyantjs/bookings/pricing-assignment";
43
41
  /**
44
42
  * Derive the age-banded traveler role from DOB. Falls back to `adult`
45
43
  * when DOB is missing so partial entries still typecheck downstream.
@@ -50,7 +48,7 @@ export function computeAgeYears(dob, now = new Date()) {
50
48
  * - adult: 18+
51
49
  */
52
50
  export function deriveTravelerRoleFromDob(dob) {
53
- const age = computeAgeYears(dob);
51
+ const age = _computeAgeYears(dob);
54
52
  if (age == null)
55
53
  return "adult";
56
54
  if (age < 2)
@@ -60,53 +58,32 @@ export function deriveTravelerRoleFromDob(dob) {
60
58
  return "adult";
61
59
  }
62
60
  /**
63
- * Find the unit whose `[minAge, maxAge]` window contains the given
64
- * DOB-derived age. Returns the unit id, or null if no match (or DOB
65
- * unset). Person-typed units are preferred; everything else is
66
- * ignored. Caller falls back to a default unit when null.
61
+ * Adapter from this file's `RoomGroupUnit` shape (UI-side, uses
62
+ * `unitId`) to the canonical `PricingAssignmentUnit` shape (uses
63
+ * `optionUnitId`). Phase 1 of voyantjs/voyant#1267 will collapse these
64
+ * by renaming the UI shape.
67
65
  */
66
+ function roomGroupUnitsAsAssignmentUnits(units) {
67
+ return units.map((u) => ({
68
+ optionId: null,
69
+ optionUnitId: u.unitId,
70
+ unitName: u.unitName,
71
+ unitCode: u.unitCode,
72
+ minAge: u.minAge,
73
+ maxAge: u.maxAge,
74
+ unitType: u.unitType,
75
+ }));
76
+ }
68
77
  function matchUnitByDob(units, dob) {
69
- if (!dob)
70
- return null;
71
- const age = computeAgeYears(dob);
72
- if (age == null)
73
- return null;
74
- const personUnits = units.filter((u) => u.unitType == null || u.unitType === "person");
75
- const match = personUnits.find((u) => (u.minAge == null || age >= u.minAge) && (u.maxAge == null || age <= u.maxAge));
76
- return match?.unitId ?? null;
78
+ return matchAssignmentUnitByDob(roomGroupUnitsAsAssignmentUnits(units), dob);
77
79
  }
78
- /**
79
- * Find the unit matching a role hint when DOB is missing. Maps the
80
- * role to a representative age and matches against `[minAge, maxAge]`.
81
- * Returns null when the role doesn't carry an age signal (e.g. `lead`).
82
- *
83
- * Routes `infant` to whichever band covers ~1y (e.g. `child_0_5`) and
84
- * `child` to whichever covers ~8y (e.g. `child_6_12`), regardless of
85
- * how the product codes the unit names.
86
- */
87
80
  function matchUnitByRoleHint(units, role) {
88
- if (!role || role === "lead")
89
- return null;
90
- const HINT_AGE = {
91
- adult: 30,
92
- child: 8,
93
- infant: 1,
94
- };
95
- const hintAge = HINT_AGE[role];
96
- if (hintAge == null)
97
- return null;
98
- // Only consider units with explicit age bands — units with null
99
- // min/max would all spuriously match any hint age.
100
- const banded = units.filter((u) => (u.unitType == null || u.unitType === "person") && (u.minAge != null || u.maxAge != null));
101
- const match = banded.find((u) => (u.minAge == null || hintAge >= u.minAge) && (u.maxAge == null || hintAge <= u.maxAge));
102
- return match?.unitId ?? null;
81
+ return matchAssignmentUnitByRoleHint(roomGroupUnitsAsAssignmentUnits(units), role);
103
82
  }
104
83
  /**
105
- * The Room dropdown lists one item per option (keyed by the option's
106
- * primary/ADULT unit id), but a traveler's `roomUnitId` can point at any
107
- * age-banded unit within that option. Map the traveler's specific unit
108
- * back to the dropdown's primary key so the Select value matches an
109
- * existing item — otherwise base-ui falls back to rendering the raw id.
84
+ * The Room dropdown lists one item per inventory option. Map any unit
85
+ * from the same option back to that option's inventory key so the
86
+ * Select value matches an existing item.
110
87
  */
111
88
  function mapUnitIdToGroupPrimary(unitId, roomGroups) {
112
89
  if (!unitId)
@@ -116,28 +93,6 @@ function mapUnitIdToGroupPrimary(unitId, roomGroups) {
116
93
  const group = roomGroups.find((g) => g.primaryUnitId === unitId || g.units.some((u) => u.unitId === unitId));
117
94
  return group?.primaryUnitId ?? unitId;
118
95
  }
119
- /**
120
- * When the operator changes the Room dropdown, preserve the traveler's
121
- * current category code (Adult/Child/Senior/…) in the destination option
122
- * if it offers a matching unit. Falls back to the destination's primary
123
- * unit when no match exists or the previous unit has no code.
124
- */
125
- function pickUnitForRoomChange(currentUnitId, nextRoomPrimaryId, roomGroups) {
126
- if (!roomGroups)
127
- return nextRoomPrimaryId;
128
- const nextGroup = roomGroups.find((g) => g.primaryUnitId === nextRoomPrimaryId);
129
- if (!nextGroup)
130
- return nextRoomPrimaryId;
131
- if (!currentUnitId)
132
- return nextGroup.primaryUnitId;
133
- const prevGroup = roomGroups.find((g) => g.primaryUnitId === currentUnitId || g.units.some((u) => u.unitId === currentUnitId));
134
- const prevUnit = prevGroup?.units.find((u) => u.unitId === currentUnitId);
135
- const prevCode = (prevUnit?.unitCode ?? "").toLowerCase();
136
- if (!prevCode)
137
- return nextGroup.primaryUnitId;
138
- const sameCategory = nextGroup.units.find((u) => (u.unitCode ?? "").toLowerCase() === prevCode);
139
- return sameCategory?.unitId ?? nextGroup.primaryUnitId;
140
- }
141
96
  const NO_ROOM = "__unassigned__";
142
97
  /**
143
98
  * Traveler list for booking-create flows. Each row can point at an existing
@@ -169,55 +124,42 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
169
124
  const removeAt = (index) => {
170
125
  onChange({ travelers: value.travelers.filter((_, i) => i !== index) });
171
126
  };
172
- // Auto-pick a room with seats available so operators don't have to
173
- // hunt for the dropdown on every traveler — they can still override
174
- // manually via the Room select. Picks the first option (ordering
175
- // mirrors the upstream `roomUnits` array, which comes from the
176
- // stepper in catalog order). When `roomGroups` is wired, also
177
- // pre-pick the matching age-banded unit within that option (DOB
178
- // first, role hint second) so the Category buttons land on the
179
- // right row and pricing reflects the operator's intent.
180
- const pickRoomUnitIdForNewTraveler = React.useCallback((dateOfBirth = null, role = null) => {
181
- if (!roomUnits || roomUnits.length === 0)
127
+ const pickPricingUnitIdForTraveler = React.useCallback((dateOfBirth = null, role = null, preferredUnitId = null) => {
128
+ if (!roomGroups || roomGroups.length === 0)
182
129
  return null;
183
- const pickedRoom = roomUnits.find((unit) => unit.remainingCapacity > 0)?.unitId ?? roomUnits[0]?.unitId ?? null;
184
- if (!pickedRoom || !roomGroups || roomGroups.length === 0)
185
- return pickedRoom;
186
- const group = roomGroups.find((g) => g.primaryUnitId === pickedRoom || g.units.some((u) => u.unitId === pickedRoom));
130
+ const group = (preferredUnitId
131
+ ? roomGroups.find((g) => g.primaryUnitId === preferredUnitId ||
132
+ g.units.some((u) => u.unitId === preferredUnitId))
133
+ : undefined) ?? roomGroups[0];
187
134
  if (!group)
188
- return pickedRoom;
135
+ return null;
189
136
  return (matchUnitByDob(group.units, dateOfBirth) ??
190
137
  matchUnitByRoleHint(group.units, role) ??
191
- group.primaryUnitId);
192
- }, [roomUnits, roomGroups]);
193
- // Race fix: travelers added before the option-units queries resolve
194
- // end up with `roomUnitId: null`. Once units arrive, back-fill any
195
- // missing assignments so the static fallback's role hint (Child /
196
- // Infant) is honored and `redistributeByAge` doesn't silently price
197
- // them as adults. Runs exactly once per units-load transition — after
198
- // that, `roomUnitId: null` is treated as the operator's explicit
199
- // "No room" choice from the Room select and left alone. Reset on
200
- // empty `roomUnits` so the next load (e.g. product change) rehydrates.
201
- const hasHydratedNullsRef = React.useRef(false);
202
- React.useEffect(() => {
138
+ group.units.find((unit) => unit.unitType == null || unit.unitType === "person")?.unitId ??
139
+ null);
140
+ }, [roomGroups]);
141
+ // Auto-pick a room with seats available so operators don't have to
142
+ // hunt for the dropdown on every traveler they can still override
143
+ // manually via the Room select. Pricing is picked from the same
144
+ // option when the product exposes person tiers.
145
+ const pickAssignmentsForNewTraveler = React.useCallback((dateOfBirth = null, role = null) => {
203
146
  if (!roomUnits || roomUnits.length === 0) {
204
- hasHydratedNullsRef.current = false;
205
- return;
147
+ return { pricingUnitId: null, inventoryUnitId: null };
206
148
  }
207
- if (hasHydratedNullsRef.current)
208
- return;
209
- hasHydratedNullsRef.current = true;
210
- if (!value.travelers.some((t) => !t.roomUnitId))
211
- return;
212
- const next = value.travelers.map((t) => t.roomUnitId ? t : { ...t, roomUnitId: pickRoomUnitIdForNewTraveler(t.dateOfBirth, t.role) });
213
- // Guard against a stray onChange if hydration can't find a unit
214
- // (e.g. empty roomGroups): no point dispatching an update that
215
- // doesn't actually change anything.
216
- const changed = next.some((t, i) => t.roomUnitId !== value.travelers[i]?.roomUnitId);
217
- if (!changed)
218
- return;
219
- onChange({ travelers: next });
220
- }, [roomUnits, value.travelers, onChange, pickRoomUnitIdForNewTraveler]);
149
+ const pickedRoom = roomUnits.find((unit) => unit.remainingCapacity > 0)?.unitId ?? roomUnits[0]?.unitId ?? null;
150
+ if (!pickedRoom || !roomGroups || roomGroups.length === 0) {
151
+ return { pricingUnitId: null, inventoryUnitId: pickedRoom };
152
+ }
153
+ const pricingUnitId = pickPricingUnitIdForTraveler(dateOfBirth, role, pickedRoom);
154
+ return { pricingUnitId, inventoryUnitId: pickedRoom };
155
+ }, [roomUnits, roomGroups, pickPricingUnitIdForTraveler]);
156
+ // Note: there is no hydration effect any more. Travelers attached
157
+ // before the option-units queries resolve get null assignment ids
158
+ // and `*UnitSource: "auto"`; the resolver in
159
+ // `@voyantjs/bookings/pricing-assignment` re-derives them at every
160
+ // preview/submit pass, and respects `"none"` (explicit No room) /
161
+ // `"manual"` (operator click) when set. Operator intent is now
162
+ // declarative on the row, not implicit in a one-shot effect.
221
163
  const addRow = () => {
222
164
  // First traveler defaults to `lead` so the operator doesn't have to
223
165
  // remember to flip the role on the initial row.
@@ -226,7 +168,12 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
226
168
  onChange({
227
169
  travelers: [
228
170
  ...value.travelers,
229
- { ...blank, roomUnitId: pickRoomUnitIdForNewTraveler(null, role) },
171
+ {
172
+ ...blank,
173
+ ...pickAssignmentsForNewTraveler(null, role),
174
+ pricingUnitSource: "auto",
175
+ inventoryUnitSource: "auto",
176
+ },
230
177
  ],
231
178
  });
232
179
  };
@@ -238,7 +185,12 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
238
185
  onChange({
239
186
  travelers: [
240
187
  ...value.travelers,
241
- { ...traveler, roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role) },
188
+ {
189
+ ...traveler,
190
+ ...pickAssignmentsForNewTraveler(traveler.dateOfBirth, role),
191
+ pricingUnitSource: "auto",
192
+ inventoryUnitSource: "auto",
193
+ },
242
194
  ],
243
195
  });
244
196
  };
@@ -248,7 +200,12 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
248
200
  onChange({
249
201
  travelers: [
250
202
  ...value.travelers,
251
- { ...traveler, roomUnitId: pickRoomUnitIdForNewTraveler(traveler.dateOfBirth, role) },
203
+ {
204
+ ...traveler,
205
+ ...pickAssignmentsForNewTraveler(traveler.dateOfBirth, role),
206
+ pricingUnitSource: "auto",
207
+ inventoryUnitSource: "auto",
208
+ },
252
209
  ],
253
210
  });
254
211
  };
@@ -289,6 +246,25 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
289
246
  phone: person.phone ?? "",
290
247
  preferredLanguage: person.preferredLanguage ?? "",
291
248
  dateOfBirth: person.dateOfBirth ?? null,
249
+ // Re-derive auto-owned unit assignments when the
250
+ // linked CRM person changes. Pricing and inventory
251
+ // stay independent: a manual room does not freeze
252
+ // DOB-driven pricing, and a manual category does
253
+ // not move the room.
254
+ ...(traveler.pricingUnitSource === "manual" ||
255
+ traveler.pricingUnitSource === "none"
256
+ ? {}
257
+ : {
258
+ pricingUnitId: pickPricingUnitIdForTraveler(person.dateOfBirth ?? null, traveler.role, traveler.inventoryUnitId),
259
+ pricingUnitSource: "auto",
260
+ }),
261
+ ...(traveler.inventoryUnitSource === "manual" ||
262
+ traveler.inventoryUnitSource === "none"
263
+ ? {}
264
+ : {
265
+ inventoryUnitId: pickAssignmentsForNewTraveler(person.dateOfBirth ?? null, traveler.role).inventoryUnitId,
266
+ inventoryUnitSource: "auto",
267
+ }),
292
268
  }), onClear: () => updateAt(index, {
293
269
  personId: null,
294
270
  firstName: "",
@@ -297,15 +273,37 @@ export function TravelersSection({ value, onChange, roomUnits, roomGroups, billi
297
273
  phone: "",
298
274
  preferredLanguage: "",
299
275
  dateOfBirth: null,
276
+ ...(traveler.pricingUnitSource === "manual" ||
277
+ traveler.pricingUnitSource === "none"
278
+ ? {}
279
+ : {
280
+ pricingUnitId: pickPricingUnitIdForTraveler(null, traveler.role, traveler.inventoryUnitId),
281
+ pricingUnitSource: "auto",
282
+ }),
283
+ ...(traveler.inventoryUnitSource === "manual" ||
284
+ traveler.inventoryUnitSource === "none"
285
+ ? {}
286
+ : {
287
+ inventoryUnitId: pickAssignmentsForNewTraveler(null, traveler.role)
288
+ .inventoryUnitId,
289
+ inventoryUnitSource: "auto",
290
+ }),
300
291
  }) }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(TravelerCategoryButtons, { traveler: traveler, roomGroups: roomGroups, fallbackLabels: {
301
292
  category: merged.category,
302
293
  adult: merged.roleAdult,
303
294
  child: merged.roleChild,
304
295
  infant: merged.roleInfant,
305
- }, onPickUnit: (unitId, nextRole) => updateAt(index, { roomUnitId: unitId, role: nextRole }) }), roomUnits && roomUnits.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.room }), _jsxs(Select, { items: roomSelectItems, value: mapUnitIdToGroupPrimary(traveler.roomUnitId, roomGroups) ?? NO_ROOM, onValueChange: (v) => updateAt(index, {
306
- roomUnitId: v === NO_ROOM || !v
307
- ? null
308
- : pickUnitForRoomChange(traveler.roomUnitId, v, roomGroups),
296
+ }, onPickUnit: (unitId, nextRole, source) => updateAt(index, {
297
+ pricingUnitId: unitId,
298
+ role: nextRole,
299
+ // Only freeze as manual when the dynamic button
300
+ // actually picked a unit. Role-only clicks via
301
+ // the static fallback stay `auto` so the
302
+ // resolver can re-derive once real units load.
303
+ pricingUnitSource: source,
304
+ }) }), roomUnits && roomUnits.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: merged.room }), _jsxs(Select, { items: roomSelectItems, value: mapUnitIdToGroupPrimary(traveler.inventoryUnitId, roomGroups) ?? NO_ROOM, onValueChange: (v) => updateAt(index, {
305
+ inventoryUnitId: v === NO_ROOM || !v ? null : v,
306
+ inventoryUnitSource: v === NO_ROOM || !v ? "none" : "manual",
309
307
  }), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: NO_ROOM, children: merged.noRoom }), roomUnits.map((unit) => (_jsx(SelectItem, { value: unit.unitId, children: unit.unitName }, unit.unitId)))] })] })] })) : null] }), _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 text-destructive", onClick: () => removeAt(index), "aria-label": merged.remove, children: [_jsx(Trash2, { className: "mr-1 h-3.5 w-3.5" }), merged.remove] }) })] }, index))) }))] }));
310
308
  }
311
309
  function TravelerPersonPicker({ personId, labels, pinnedPeople = [], onSelect, onClear, }) {
@@ -375,6 +373,7 @@ function createTravelerFromPerson(person, role) {
375
373
  const dateOfBirth = person.dateOfBirth ?? null;
376
374
  const effectiveRole = role === "lead" ? "lead" : deriveTravelerRoleFromDob(dateOfBirth);
377
375
  return {
376
+ clientTravelerKey: createClientTravelerKey(),
378
377
  personId: person.id,
379
378
  firstName: person.firstName,
380
379
  lastName: person.lastName,
@@ -383,7 +382,10 @@ function createTravelerFromPerson(person, role) {
383
382
  preferredLanguage: person.preferredLanguage ?? "",
384
383
  role: effectiveRole,
385
384
  dateOfBirth,
386
- roomUnitId: null,
385
+ pricingUnitId: null,
386
+ inventoryUnitId: null,
387
+ pricingUnitSource: "auto",
388
+ inventoryUnitSource: "auto",
387
389
  };
388
390
  }
389
391
  function formatPersonName(person) {
@@ -409,11 +411,13 @@ function formatPerson(person) {
409
411
  */
410
412
  function TravelerCategoryButtons({ traveler, roomGroups, fallbackLabels, onPickUnit, }) {
411
413
  const group = React.useMemo(() => {
412
- if (!roomGroups || !traveler.roomUnitId)
414
+ if (!roomGroups)
415
+ return undefined;
416
+ const assignedUnitId = traveler.inventoryUnitId ?? traveler.pricingUnitId;
417
+ if (!assignedUnitId)
413
418
  return undefined;
414
- return roomGroups.find((g) => g.primaryUnitId === traveler.roomUnitId ||
415
- g.units.some((u) => u.unitId === traveler.roomUnitId));
416
- }, [roomGroups, traveler.roomUnitId]);
419
+ return roomGroups.find((g) => g.primaryUnitId === assignedUnitId || g.units.some((u) => u.unitId === assignedUnitId));
420
+ }, [roomGroups, traveler.inventoryUnitId, traveler.pricingUnitId]);
417
421
  // Surface only person-typed units (Adult, Child, Senior, Infant,
418
422
  // …). Vehicles / rooms / services aren't categories the operator
419
423
  // toggles on a per-traveler basis. If the option has only one
@@ -440,16 +444,21 @@ function TravelerCategoryButtons({ traveler, roomGroups, fallbackLabels, onPickU
440
444
  ].map(([category, label]) => {
441
445
  const { active, nextRole, shouldUpdate } = getStaticTravelerCategoryButtonState(traveler, category);
442
446
  return (_jsx(Button, { type: "button", size: "sm", variant: active ? "default" : "outline", className: "h-7 text-xs", onClick: () => {
447
+ // Static fallback: operator chose a role, not a
448
+ // concrete unit. Pass `source: "auto"` so the
449
+ // resolver re-derives the unit instead of freezing
450
+ // a stale auto-assignment as manual.
443
451
  if (shouldUpdate)
444
- onPickUnit(traveler.roomUnitId, nextRole);
452
+ onPickUnit(traveler.pricingUnitId, nextRole, "auto");
445
453
  }, children: label }, category));
446
454
  }) })] }));
447
455
  }
448
456
  return (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { className: "text-xs", children: fallbackLabels.category }), _jsx("div", { className: "grid gap-1", style: { gridTemplateColumns: `repeat(${categoryUnits.length}, minmax(0, 1fr))` }, children: categoryUnits.map((unit) => {
449
457
  const { active, nextRole, shouldUpdate } = getDynamicTravelerCategoryButtonState(traveler, unit);
450
458
  return (_jsx(Button, { type: "button", size: "sm", variant: active ? "default" : "outline", className: "h-7 text-xs", onClick: () => {
459
+ // Dynamic button: real unit pick, freeze as manual.
451
460
  if (shouldUpdate)
452
- onPickUnit(unit.unitId, nextRole);
461
+ onPickUnit(unit.unitId, nextRole, "manual");
453
462
  }, title: unit.minAge != null || unit.maxAge != null
454
463
  ? `${unit.minAge ?? "0"}–${unit.maxAge ?? "∞"}`
455
464
  : undefined, children: unit.unitName }, unit.unitId));
package/dist/i18n/en.d.ts CHANGED
@@ -487,6 +487,7 @@ export declare const bookingsUiEn: {
487
487
  fillsSlotCapacity: string;
488
488
  decreaseUnitPrefix: string;
489
489
  increaseUnitPrefix: string;
490
+ reviewLine: string;
490
491
  };
491
492
  };
492
493
  sharedRoomSection: {
@@ -1124,6 +1125,9 @@ export declare const bookingsUiEn: {
1124
1125
  confirmFailedPrefix: string;
1125
1126
  confirmFailed: string;
1126
1127
  createFailed: string;
1128
+ payloadResolverMismatchDetails: string;
1129
+ payloadResolverMismatchFallback: string;
1130
+ payloadResolverMismatchLine: string;
1127
1131
  };
1128
1132
  actions: {
1129
1133
  createDraftBooking: string;
@@ -1311,6 +1315,7 @@ export declare const bookingsUiEn: {
1311
1315
  actions: {
1312
1316
  open: string;
1313
1317
  view: string;
1318
+ convertToInvoice: string;
1314
1319
  edit: string;
1315
1320
  delete: string;
1316
1321
  };
@@ -1 +1 @@
1
- {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA42CK,CAAA"}
1
+ {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/i18n/en.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAm3CK,CAAA"}
package/dist/i18n/en.js CHANGED
@@ -487,6 +487,7 @@ export const bookingsUiEn = {
487
487
  fillsSlotCapacity: "fills slot capacity",
488
488
  decreaseUnitPrefix: "Decrease",
489
489
  increaseUnitPrefix: "Increase",
490
+ reviewLine: "Review this line",
490
491
  },
491
492
  },
492
493
  sharedRoomSection: {
@@ -1124,6 +1125,9 @@ export const bookingsUiEn = {
1124
1125
  confirmFailedPrefix: "Booking created but confirm failed: {message}",
1125
1126
  confirmFailed: "Booking created but confirm failed",
1126
1127
  createFailed: "Failed to create booking",
1128
+ payloadResolverMismatchDetails: "Booking options are out of sync. Review these lines: {details}.",
1129
+ payloadResolverMismatchFallback: "Booking options are out of sync. Review the selected traveler and option lines.",
1130
+ payloadResolverMismatchLine: "{label}: sent {submittedQuantity}, expected {resolvedQuantity}",
1127
1131
  },
1128
1132
  actions: {
1129
1133
  createDraftBooking: "Create draft booking",
@@ -1311,6 +1315,7 @@ export const bookingsUiEn = {
1311
1315
  actions: {
1312
1316
  open: "Open payment menu",
1313
1317
  view: "View payment",
1318
+ convertToInvoice: "Convert to invoice",
1314
1319
  edit: "Edit payment",
1315
1320
  delete: "Delete payment",
1316
1321
  },
@@ -401,6 +401,7 @@ export type BookingsUiMessages = {
401
401
  fillsSlotCapacity: string;
402
402
  decreaseUnitPrefix: string;
403
403
  increaseUnitPrefix: string;
404
+ reviewLine: string;
404
405
  };
405
406
  };
406
407
  sharedRoomSection: {
@@ -983,6 +984,9 @@ export type BookingsUiMessages = {
983
984
  confirmFailedPrefix: string;
984
985
  confirmFailed: string;
985
986
  createFailed: string;
987
+ payloadResolverMismatchDetails: string;
988
+ payloadResolverMismatchFallback: string;
989
+ payloadResolverMismatchLine: string;
986
990
  };
987
991
  actions: {
988
992
  createDraftBooking: string;
@@ -1164,6 +1168,7 @@ export type BookingsUiMessages = {
1164
1168
  /** Trigger button screen-reader label. */
1165
1169
  open: string;
1166
1170
  view: string;
1171
+ convertToInvoice: string;
1167
1172
  edit: string;
1168
1173
  delete: string;
1169
1174
  };