@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.
- package/README.md +8 -8
- package/dist/admin/bookings-host.d.ts +1 -2
- package/dist/admin/bookings-host.js +2 -3
- package/dist/admin/index.d.ts +6 -44
- package/dist/admin/index.js +2 -65
- package/dist/components/booking-create-utils.d.ts +1 -1
- package/dist/components/booking-create-utils.js +5 -17
- package/dist/components/booking-dialog.d.ts +4 -19
- package/dist/components/booking-dialog.js +3 -12
- package/dist/components/booking-list.d.ts +1 -2
- package/dist/components/booking-list.js +5 -13
- package/dist/components/bookings-page.d.ts +1 -2
- package/dist/components/bookings-page.js +2 -2
- package/dist/hooks/booking-create-types.d.ts +34 -0
- package/dist/hooks/booking-create-types.js +1 -0
- package/dist/hooks/index.d.ts +2 -4
- package/dist/hooks/index.js +1 -4
- package/dist/hooks/use-booking-mutation.d.ts +3 -65
- package/dist/hooks/use-booking-mutation.js +1 -10
- package/dist/i18n/en-create-list.d.ts +0 -1
- package/dist/i18n/en-create-list.js +0 -1
- package/dist/i18n/en.d.ts +0 -1
- package/dist/i18n/messages-create-list.d.ts +0 -1
- package/dist/i18n/provider.d.ts +0 -2
- package/dist/i18n/ro-create-list.d.ts +0 -1
- package/dist/i18n/ro-create-list.js +0 -1
- package/dist/i18n/ro.d.ts +0 -1
- package/dist/journey/components/booking-journey-rules.d.ts +0 -22
- package/dist/journey/components/booking-journey-rules.js +0 -54
- package/dist/journey/components/journey-steps/payment-step.js +1 -1
- package/dist/journey/components/journey-steps/shared.d.ts +3 -3
- package/dist/journey/components/journey-steps.d.ts +1 -1
- package/dist/journey/components/journey-steps.js +1 -1
- package/dist/journey/index.d.ts +2 -12
- package/dist/journey/index.js +1 -11
- package/dist/journey/types.d.ts +3 -208
- package/dist/journey/types.js +2 -6
- package/dist/storefront/index.d.ts +0 -2
- package/dist/storefront/index.js +0 -2
- package/dist/ui.d.ts +0 -2
- package/dist/ui.js +0 -2
- package/package.json +33 -33
- package/dist/admin/booking-journey-host.d.ts +0 -23
- package/dist/admin/booking-journey-host.js +0 -280
- package/dist/admin/journey-billing-duplicate-warning.d.ts +0 -2
- package/dist/admin/journey-billing-duplicate-warning.js +0 -26
- package/dist/admin/journey-departure-picker.d.ts +0 -6
- package/dist/admin/journey-departure-picker.js +0 -91
- package/dist/admin/journey-travel-credit-picker.d.ts +0 -2
- package/dist/admin/journey-travel-credit-picker.js +0 -72
- package/dist/admin/journey-units-picker.d.ts +0 -10
- package/dist/admin/journey-units-picker.js +0 -60
- package/dist/admin/pages/booking-journey-page.d.ts +0 -11
- package/dist/admin/pages/booking-journey-page.js +0 -26
- package/dist/admin/pages/booking-new-page.d.ts +0 -15
- package/dist/admin/pages/booking-new-page.js +0 -50
- package/dist/components/booking-create-page.d.ts +0 -13
- package/dist/components/booking-create-page.js +0 -11
- package/dist/components/booking-create-sheet.d.ts +0 -34
- package/dist/components/booking-create-sheet.js +0 -815
- package/dist/hooks/use-booking-convert-mutation.d.ts +0 -80
- package/dist/hooks/use-booking-convert-mutation.js +0 -24
- package/dist/hooks/use-booking-create-mutation.d.ts +0 -336
- package/dist/hooks/use-booking-create-mutation.js +0 -43
- package/dist/hooks/use-booking-dual-create-mutation.d.ts +0 -337
- package/dist/hooks/use-booking-dual-create-mutation.js +0 -45
- package/dist/journey/components/booking-journey.d.ts +0 -2
- package/dist/journey/components/booking-journey.js +0 -485
- package/dist/storefront/storefront-booking-errors.d.ts +0 -12
- package/dist/storefront/storefront-booking-errors.js +0 -50
- package/dist/storefront/storefront-booking-journey.d.ts +0 -71
- package/dist/storefront/storefront-booking-journey.js +0 -327
- package/dist/storefront/storefront-booking-page.d.ts +0 -35
- package/dist/storefront/storefront-booking-page.js +0 -215
- package/dist/storefront/storefront-checkout-bodies.d.ts +0 -36
- package/dist/storefront/storefront-checkout-bodies.js +0 -69
|
@@ -1,815 +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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
-
import { resolveBookingDraft, resolveBookingExtraLines, travelersToRows, } from "@voyant-travel/bookings/pricing-assignment";
|
|
6
|
-
import { useOptionUnitPriceRules, usePricingCategories, } from "@voyant-travel/commerce-react/pricing";
|
|
7
|
-
import { useAddresses } from "@voyant-travel/identity-react";
|
|
8
|
-
import { useProduct } from "@voyant-travel/inventory-react";
|
|
9
|
-
import { availabilityQueryKeys, getSlotQueryOptions, useSlots, useSlotUnitAvailability, useVoyantAvailabilityContext, } from "@voyant-travel/operations-react/availability";
|
|
10
|
-
import { useOrganization, usePerson } from "@voyant-travel/relationships-react";
|
|
11
|
-
import { Button, Checkbox, Label, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Textarea, } from "@voyant-travel/ui/components";
|
|
12
|
-
import { AsyncCombobox } from "@voyant-travel/ui/components/async-combobox";
|
|
13
|
-
import { Loader2 } from "lucide-react";
|
|
14
|
-
import * as React from "react";
|
|
15
|
-
import { formatMessage, useBookingsUiI18nOrDefault, useBookingsUiMessagesOrDefault, } from "../i18n/provider.js";
|
|
16
|
-
import { useBookingCreateMutation, usePricingPreview, VoyantApiError, } from "../index.js";
|
|
17
|
-
import { findAlreadyPaidInstallmentMissingPaymentDate, formatPayloadResolverMismatchError, generateBookingNumber, hasAnyPaidPayment, inferTravelerPricingCategoryId, isBookingInventoryUnit, isPayloadResolverMismatchBody, mergePricingRoomMetadata, normalizeBookingUnit, paymentScheduleToRows, pricingSnapshotRoomUnits, sameRoomUnits, stripOptionPrefix, stripUnitSuffix, } from "./booking-create-form-utils.js";
|
|
18
|
-
import { BookingPreviewCard } from "./booking-create-preview-card.js";
|
|
19
|
-
import { ProductExtrasPickerSection } from "./booking-create-product-extras-picker.js";
|
|
20
|
-
import { getBookableDepartureSlots, getOverCapacityInventoryAssignments, getSelectedSharedRoomUnitId, getTravelerAssignableStepperUnits, isRealBookingEmail, itemLinesToRows, validateBillingPersonContact, } from "./booking-create-utils.js";
|
|
21
|
-
import { emptyOptionUnitsStepperValue, OptionUnitsStepperSection, } from "./option-units-stepper-section.js";
|
|
22
|
-
import { emptyPaymentScheduleValue, PaymentScheduleSection, } from "./payment-schedule-section.js";
|
|
23
|
-
import { emptyPersonPickerValue, PersonPickerSection, } from "./person-picker-section.js";
|
|
24
|
-
import { ProductPickerSection } from "./product-picker-section.js";
|
|
25
|
-
import { emptySharedRoomValue, SharedRoomSection, } from "./shared-room-section.js";
|
|
26
|
-
import { emptyTravelCreditPickerValue, TravelCreditPickerSection, } from "./travel-credit-picker-section.js";
|
|
27
|
-
import { emptyTravelerListValue, TravelersSection, } from "./travelers-section.js";
|
|
28
|
-
/**
|
|
29
|
-
* Operator booking-create sheet. Composes the booking-create picker
|
|
30
|
-
* sections — product, departure, rooms, person, shared-room, travelers,
|
|
31
|
-
* price breakdown, Travel Credit, payment schedule, and submits via the atomic
|
|
32
|
-
* `POST /v1/admin/bookings/create` endpoint so partial failures can't
|
|
33
|
-
* leave orphan state.
|
|
34
|
-
*
|
|
35
|
-
* Normally consumed via `BookingDialog` which delegates here when no
|
|
36
|
-
* `booking` prop is passed. Apps that need a bespoke flow can install the
|
|
37
|
-
* sections individually and assemble their own sheet instead of forking.
|
|
38
|
-
*/
|
|
39
|
-
export function BookingCreateSheet({ open, onOpenChange, onCreated, defaultProductId, defaultSlotId, }) {
|
|
40
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
41
|
-
return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", className: "flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[64rem]!", children: [_jsxs(SheetHeader, { className: "border-b px-6 py-4", children: [_jsx(SheetTitle, { children: messages.bookingCreateDialog.title }), _jsx(SheetDescription, { className: "sr-only", children: messages.bookingCreateDialog.title })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto px-6 py-4", children: _jsx(BookingCreateForm, { enabled: open, defaultProductId: defaultProductId, defaultSlotId: defaultSlotId, onCancel: () => onOpenChange(false), onCreated: (booking) => {
|
|
42
|
-
onOpenChange(false);
|
|
43
|
-
onCreated?.(booking);
|
|
44
|
-
} }) })] }) }));
|
|
45
|
-
}
|
|
46
|
-
export function BookingCreateForm({ onCreated, defaultProductId, defaultSlotId, enabled = true, onCancel, }) {
|
|
47
|
-
const [product, setProduct] = React.useState({
|
|
48
|
-
productId: defaultProductId ?? "",
|
|
49
|
-
optionId: null,
|
|
50
|
-
});
|
|
51
|
-
const [slotId, setSlotId] = React.useState(defaultSlotId ?? null);
|
|
52
|
-
const [rooms, setRooms] = React.useState(emptyOptionUnitsStepperValue);
|
|
53
|
-
const [roomUnits, setRoomUnits] = React.useState([]);
|
|
54
|
-
const [extraLines, setExtraLines] = React.useState([]);
|
|
55
|
-
const [person, setPerson] = React.useState(emptyPersonPickerValue);
|
|
56
|
-
const [sharedRoom, setSharedRoom] = React.useState(emptySharedRoomValue);
|
|
57
|
-
const [travelers, setTravelers] = React.useState(emptyTravelerListValue);
|
|
58
|
-
const [travelCredit, setTravelCredit] = React.useState(emptyTravelCreditPickerValue);
|
|
59
|
-
const [pricing, setPricing] = React.useState(null);
|
|
60
|
-
const [paymentSchedule, setPaymentScheduleState] = React.useState(emptyPaymentScheduleValue);
|
|
61
|
-
// Tracks whether the operator has manually touched the schedule. The
|
|
62
|
-
// "default the due date to departure" effect below uses this to bow
|
|
63
|
-
// out once the operator has typed their own dates / amounts / paid
|
|
64
|
-
// markers — otherwise we'd thrash their input every time the slot's
|
|
65
|
-
// startsAt re-resolves (combobox re-renders, slot query refetch, …).
|
|
66
|
-
const paymentScheduleTouchedRef = React.useRef(false);
|
|
67
|
-
const setPaymentSchedule = React.useCallback((next) => {
|
|
68
|
-
paymentScheduleTouchedRef.current = true;
|
|
69
|
-
setPaymentScheduleState(next);
|
|
70
|
-
}, []);
|
|
71
|
-
/**
|
|
72
|
-
* Mutually-exclusive document toggles. "Proforma" issues a single
|
|
73
|
-
* placeholder invoice; "Invoice + contract" issues a final invoice
|
|
74
|
-
* alongside the customer contract. Either off → no documents.
|
|
75
|
-
*/
|
|
76
|
-
const [generateProforma, setGenerateProformaState] = React.useState(false);
|
|
77
|
-
const [generateInvoiceAndContract, setGenerateInvoiceAndContractState] = React.useState(false);
|
|
78
|
-
const setGenerateProforma = (next) => {
|
|
79
|
-
setGenerateProformaState(next);
|
|
80
|
-
if (next)
|
|
81
|
-
setGenerateInvoiceAndContractState(false);
|
|
82
|
-
};
|
|
83
|
-
const setGenerateInvoiceAndContract = (next) => {
|
|
84
|
-
setGenerateInvoiceAndContractState(next);
|
|
85
|
-
if (next)
|
|
86
|
-
setGenerateProformaState(false);
|
|
87
|
-
};
|
|
88
|
-
const [notes, setNotes] = React.useState("");
|
|
89
|
-
// Only relevant when the derived status is `confirmed`: when off,
|
|
90
|
-
// the status transition carries `suppressNotifications: true` so
|
|
91
|
-
// the auto-dispatch subscriber skips the customer email + document
|
|
92
|
-
// bundle. Defaults on so the operator opts out, not in.
|
|
93
|
-
const [notifyTraveler, setNotifyTraveler] = React.useState(true);
|
|
94
|
-
const [error, setError] = React.useState(null);
|
|
95
|
-
const [payloadMismatchUnitIds, setPayloadMismatchUnitIds] = React.useState([]);
|
|
96
|
-
const { formatDate } = useBookingsUiI18nOrDefault();
|
|
97
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
98
|
-
const availabilityClient = useVoyantAvailabilityContext();
|
|
99
|
-
const defaultSlotQuery = useQuery({
|
|
100
|
-
...getSlotQueryOptions(availabilityClient, defaultSlotId),
|
|
101
|
-
enabled: enabled && Boolean(defaultSlotId),
|
|
102
|
-
});
|
|
103
|
-
const defaultSlot = defaultSlotQuery.data?.data ?? null;
|
|
104
|
-
const resolvedDefaultProductId = defaultProductId ?? defaultSlot?.productId ?? "";
|
|
105
|
-
React.useEffect(() => {
|
|
106
|
-
if (!enabled) {
|
|
107
|
-
setProduct({
|
|
108
|
-
productId: resolvedDefaultProductId,
|
|
109
|
-
optionId: defaultSlotId ? (defaultSlot?.optionId ?? null) : null,
|
|
110
|
-
});
|
|
111
|
-
setSlotId(defaultSlotId ?? null);
|
|
112
|
-
setRooms(emptyOptionUnitsStepperValue);
|
|
113
|
-
setRoomUnits([]);
|
|
114
|
-
setExtraLines([]);
|
|
115
|
-
setPerson(emptyPersonPickerValue);
|
|
116
|
-
setSharedRoom(emptySharedRoomValue);
|
|
117
|
-
setTravelers(emptyTravelerListValue);
|
|
118
|
-
setTravelCredit(emptyTravelCreditPickerValue);
|
|
119
|
-
setPricing(null);
|
|
120
|
-
setPaymentScheduleState(emptyPaymentScheduleValue);
|
|
121
|
-
paymentScheduleTouchedRef.current = false;
|
|
122
|
-
setGenerateProformaState(false);
|
|
123
|
-
setGenerateInvoiceAndContractState(false);
|
|
124
|
-
setNotes("");
|
|
125
|
-
setNotifyTraveler(true);
|
|
126
|
-
setError(null);
|
|
127
|
-
setPayloadMismatchUnitIds([]);
|
|
128
|
-
}
|
|
129
|
-
else if (resolvedDefaultProductId) {
|
|
130
|
-
setProduct((prev) => {
|
|
131
|
-
const optionId = defaultSlotId
|
|
132
|
-
? (defaultSlot?.optionId ?? null)
|
|
133
|
-
: prev.productId === resolvedDefaultProductId
|
|
134
|
-
? prev.optionId
|
|
135
|
-
: null;
|
|
136
|
-
return prev.productId === resolvedDefaultProductId && prev.optionId === optionId
|
|
137
|
-
? prev
|
|
138
|
-
: { productId: resolvedDefaultProductId, optionId };
|
|
139
|
-
});
|
|
140
|
-
if (defaultSlotId)
|
|
141
|
-
setSlotId(defaultSlotId);
|
|
142
|
-
}
|
|
143
|
-
}, [enabled, resolvedDefaultProductId, defaultSlotId, defaultSlot?.optionId]);
|
|
144
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: booking-create intentionally resets transient departure state only when product id changes; option changes are reconciled against the selected departure below.
|
|
145
|
-
React.useEffect(() => {
|
|
146
|
-
setSlotId(defaultSlotId ?? null);
|
|
147
|
-
setRooms(emptyOptionUnitsStepperValue);
|
|
148
|
-
setRoomUnits([]);
|
|
149
|
-
setSharedRoom(emptySharedRoomValue);
|
|
150
|
-
setExtraLines([]);
|
|
151
|
-
setPayloadMismatchUnitIds([]);
|
|
152
|
-
}, [product.productId, defaultSlotId]);
|
|
153
|
-
const [slotsFromIso, setSlotsFromIso] = React.useState(() => new Date().toISOString());
|
|
154
|
-
React.useEffect(() => {
|
|
155
|
-
if (enabled && product.productId)
|
|
156
|
-
setSlotsFromIso(new Date().toISOString());
|
|
157
|
-
}, [enabled, product.productId]);
|
|
158
|
-
const { data: slotsData } = useSlots({
|
|
159
|
-
productId: product.productId || undefined,
|
|
160
|
-
status: "open",
|
|
161
|
-
startsAtFrom: slotsFromIso,
|
|
162
|
-
limit: 100,
|
|
163
|
-
enabled: enabled && Boolean(product.productId),
|
|
164
|
-
});
|
|
165
|
-
const allOpenSlots = React.useMemo(() => {
|
|
166
|
-
return getBookableDepartureSlots(slotsData?.data ?? [], {
|
|
167
|
-
nowIso: slotsFromIso,
|
|
168
|
-
optionId: null,
|
|
169
|
-
});
|
|
170
|
-
}, [slotsData?.data, slotsFromIso]);
|
|
171
|
-
const slots = React.useMemo(() => {
|
|
172
|
-
const optionSlots = getBookableDepartureSlots(slotsData?.data ?? [], {
|
|
173
|
-
nowIso: slotsFromIso,
|
|
174
|
-
optionId: product.optionId,
|
|
175
|
-
});
|
|
176
|
-
return optionSlots.length > 0 ? optionSlots : allOpenSlots;
|
|
177
|
-
}, [slotsData?.data, slotsFromIso, product.optionId, allOpenSlots]);
|
|
178
|
-
const selectedSlot = React.useMemo(() => slots.find((slot) => slot.id === slotId) ?? (defaultSlot?.id === slotId ? defaultSlot : null), [slots, slotId, defaultSlot]);
|
|
179
|
-
// Default the single Full-mode installment's due date to the departure
|
|
180
|
-
// day so a trip starting weeks/months later doesn't surface today's
|
|
181
|
-
// date as the payment due date. Skip once the operator has touched
|
|
182
|
-
// the schedule (mode change, custom amount, alternate date, …) so we
|
|
183
|
-
// never overwrite their edits.
|
|
184
|
-
const departureDateIso = selectedSlot?.startsAt?.slice(0, 10) ?? null;
|
|
185
|
-
React.useEffect(() => {
|
|
186
|
-
if (!departureDateIso || paymentScheduleTouchedRef.current)
|
|
187
|
-
return;
|
|
188
|
-
setPaymentScheduleState((prev) => {
|
|
189
|
-
if (prev.mode !== "full" || prev.installments.length !== 1)
|
|
190
|
-
return prev;
|
|
191
|
-
const installment = prev.installments[0];
|
|
192
|
-
if (!installment || installment.dueDate === departureDateIso)
|
|
193
|
-
return prev;
|
|
194
|
-
return {
|
|
195
|
-
...prev,
|
|
196
|
-
installments: [{ ...installment, dueDate: departureDateIso }],
|
|
197
|
-
};
|
|
198
|
-
});
|
|
199
|
-
}, [departureDateIso]);
|
|
200
|
-
const setSelectedSlot = React.useCallback((nextSlotId) => {
|
|
201
|
-
setPayloadMismatchUnitIds([]);
|
|
202
|
-
const selectedSlot = nextSlotId ? allOpenSlots.find((slot) => slot.id === nextSlotId) : null;
|
|
203
|
-
if (selectedSlot?.optionId && selectedSlot.optionId !== product.optionId) {
|
|
204
|
-
setProduct((prev) => ({ ...prev, optionId: selectedSlot.optionId }));
|
|
205
|
-
}
|
|
206
|
-
setSlotId(nextSlotId);
|
|
207
|
-
}, [allOpenSlots, product.optionId]);
|
|
208
|
-
React.useEffect(() => {
|
|
209
|
-
setRooms(emptyOptionUnitsStepperValue);
|
|
210
|
-
setRoomUnits([]);
|
|
211
|
-
setPayloadMismatchUnitIds([]);
|
|
212
|
-
if (!slotId || !product.optionId)
|
|
213
|
-
return;
|
|
214
|
-
const selectedDeparture = allOpenSlots.find((slot) => slot.id === slotId) ??
|
|
215
|
-
(defaultSlot?.id === slotId ? defaultSlot : null);
|
|
216
|
-
if (selectedDeparture?.optionId && selectedDeparture.optionId !== product.optionId) {
|
|
217
|
-
if (defaultSlotId && selectedDeparture.id === defaultSlotId) {
|
|
218
|
-
setProduct((prev) => ({ ...prev, optionId: selectedDeparture.optionId }));
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
setSlotId(null);
|
|
222
|
-
}
|
|
223
|
-
}, [allOpenSlots, product.optionId, slotId, defaultSlotId, defaultSlot]);
|
|
224
|
-
const formatSlotLabel = React.useCallback((slot) => {
|
|
225
|
-
const date = formatDate(slot.startsAt, {
|
|
226
|
-
year: "numeric",
|
|
227
|
-
month: "short",
|
|
228
|
-
day: "numeric",
|
|
229
|
-
});
|
|
230
|
-
const remaining = !slot.unlimited && typeof slot.remainingPax === "number"
|
|
231
|
-
? ` · ${slot.remainingPax} ${messages.bookingCreateDialog.labels.remainingCapacity}`
|
|
232
|
-
: "";
|
|
233
|
-
return `${date}${remaining}`;
|
|
234
|
-
}, [formatDate, messages]);
|
|
235
|
-
const slotUnitAvailability = useSlotUnitAvailability({
|
|
236
|
-
slotId: slotId ?? undefined,
|
|
237
|
-
enabled: enabled && Boolean(slotId),
|
|
238
|
-
});
|
|
239
|
-
const pricingPreview = usePricingPreview({
|
|
240
|
-
productId: product.productId,
|
|
241
|
-
optionId: product.optionId,
|
|
242
|
-
enabled: enabled && Boolean(product.productId),
|
|
243
|
-
});
|
|
244
|
-
const pricingCategoriesQuery = usePricingCategories({
|
|
245
|
-
active: true,
|
|
246
|
-
limit: 200,
|
|
247
|
-
enabled: enabled && Boolean(product.productId),
|
|
248
|
-
});
|
|
249
|
-
const optionUnitPriceRulesQuery = useOptionUnitPriceRules({
|
|
250
|
-
optionId: product.optionId ?? selectedSlot?.optionId ?? undefined,
|
|
251
|
-
active: true,
|
|
252
|
-
limit: 200,
|
|
253
|
-
enabled: enabled && Boolean(product.productId),
|
|
254
|
-
});
|
|
255
|
-
const handleRoomUnitsChange = React.useCallback((units) => {
|
|
256
|
-
setRoomUnits((prev) => (sameRoomUnits(prev, units) ? prev : units));
|
|
257
|
-
}, []);
|
|
258
|
-
const pricingRoomUnits = React.useMemo(() => pricingSnapshotRoomUnits(pricingPreview.data?.data), [pricingPreview.data]);
|
|
259
|
-
const hasRoomPricingMatrix = pricingRoomUnits.length > 0;
|
|
260
|
-
const bookingUnits = React.useMemo(() => mergePricingRoomMetadata(roomUnits, pricingRoomUnits), [roomUnits, pricingRoomUnits]);
|
|
261
|
-
// Room choices presented to the traveler row are inventory options
|
|
262
|
-
// (e.g. "Standard double", "Junior suite upgrade"). The age-band
|
|
263
|
-
// lives separately on the traveler as a pricing unit.
|
|
264
|
-
const roomUnitOptions = React.useMemo(() => {
|
|
265
|
-
const sourceUnits = bookingUnits.length > 0 ? bookingUnits : (slotUnitAvailability.data?.data ?? []);
|
|
266
|
-
const units = sourceUnits.filter(isBookingInventoryUnit);
|
|
267
|
-
if (units.length === 0)
|
|
268
|
-
return [];
|
|
269
|
-
return units
|
|
270
|
-
.filter((unit) => (rooms.quantities[unit.optionUnitId] ?? 0) > 0)
|
|
271
|
-
.map((unit) => {
|
|
272
|
-
const totalQty = rooms.quantities[unit.optionUnitId] ?? 0;
|
|
273
|
-
const occupancyMax = Math.max(1, unit.occupancyMax ?? 1);
|
|
274
|
-
const seats = totalQty * occupancyMax;
|
|
275
|
-
const assigned = travelers.travelers.filter((traveler) => traveler.inventoryUnitId === unit.optionUnitId).length;
|
|
276
|
-
return {
|
|
277
|
-
unitId: unit.optionUnitId,
|
|
278
|
-
unitName: stripOptionPrefix(unit.unitName),
|
|
279
|
-
remainingCapacity: Math.max(0, seats - assigned),
|
|
280
|
-
};
|
|
281
|
-
});
|
|
282
|
-
}, [bookingUnits, slotUnitAvailability.data, rooms.quantities, travelers.travelers]);
|
|
283
|
-
// Per-option breakdown of all configured units, with the
|
|
284
|
-
// attributes the TravelersSection's dynamic category buttons need
|
|
285
|
-
// (unitCode/min-max/unitType). Mirrors the grouping logic in
|
|
286
|
-
// `roomUnitOptions` but exposes every unit instead of collapsing
|
|
287
|
-
// to one primary.
|
|
288
|
-
const roomGroups = React.useMemo(() => {
|
|
289
|
-
if (bookingUnits.length === 0)
|
|
290
|
-
return [];
|
|
291
|
-
const groups = new Map();
|
|
292
|
-
for (const rawUnit of bookingUnits) {
|
|
293
|
-
const u = normalizeBookingUnit(rawUnit);
|
|
294
|
-
if (!u.optionId)
|
|
295
|
-
continue;
|
|
296
|
-
const groupKey = u.optionId;
|
|
297
|
-
const isInventory = isBookingInventoryUnit(u);
|
|
298
|
-
const isAdultCoded = (u.unitCode ?? "").toUpperCase() === "ADULT";
|
|
299
|
-
const unit = {
|
|
300
|
-
unitId: u.optionUnitId,
|
|
301
|
-
// Strip the "Option name - " prefix the stepper applies when
|
|
302
|
-
// an option has multiple units; the per-unit label is enough
|
|
303
|
-
// for a category button.
|
|
304
|
-
unitName: stripOptionPrefix(u.unitName),
|
|
305
|
-
unitCode: u.unitCode ?? null,
|
|
306
|
-
minAge: u.minAge ?? null,
|
|
307
|
-
maxAge: u.maxAge ?? null,
|
|
308
|
-
unitType: (u.unitType ?? null),
|
|
309
|
-
};
|
|
310
|
-
const existing = groups.get(groupKey);
|
|
311
|
-
if (existing) {
|
|
312
|
-
existing.units.push(unit);
|
|
313
|
-
if (isInventory)
|
|
314
|
-
existing.primaryUnitId = u.optionUnitId;
|
|
315
|
-
else if (isAdultCoded &&
|
|
316
|
-
!existing.units.some((candidate) => candidate.unitType === "room" || candidate.unitType === "vehicle")) {
|
|
317
|
-
existing.primaryUnitId = u.optionUnitId;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
else {
|
|
321
|
-
groups.set(groupKey, {
|
|
322
|
-
optionId: groupKey,
|
|
323
|
-
optionName: stripUnitSuffix(u.unitName),
|
|
324
|
-
primaryUnitId: u.optionUnitId,
|
|
325
|
-
units: [unit],
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
return Array.from(groups.values());
|
|
330
|
-
}, [bookingUnits]);
|
|
331
|
-
const travelerPricingCategories = React.useMemo(() => {
|
|
332
|
-
const snapshot = pricingPreview.data?.data;
|
|
333
|
-
const categoriesById = new Map();
|
|
334
|
-
const bookingUnitIds = new Set(bookingUnits.map((unit) => unit.optionUnitId));
|
|
335
|
-
for (const category of pricingCategoriesQuery.data?.data ?? []) {
|
|
336
|
-
categoriesById.set(category.id, category);
|
|
337
|
-
}
|
|
338
|
-
for (const category of snapshot?.pricingCategories ?? []) {
|
|
339
|
-
categoriesById.set(category.id, category);
|
|
340
|
-
}
|
|
341
|
-
const unitIdsByCategoryId = new Map();
|
|
342
|
-
for (const unitPrice of snapshot?.unitPrices ?? []) {
|
|
343
|
-
if (!unitPrice.pricingCategoryId)
|
|
344
|
-
continue;
|
|
345
|
-
if (bookingUnitIds.size > 0 && !bookingUnitIds.has(unitPrice.unitId))
|
|
346
|
-
continue;
|
|
347
|
-
const existing = unitIdsByCategoryId.get(unitPrice.pricingCategoryId) ?? new Set();
|
|
348
|
-
existing.add(unitPrice.unitId);
|
|
349
|
-
unitIdsByCategoryId.set(unitPrice.pricingCategoryId, existing);
|
|
350
|
-
}
|
|
351
|
-
for (const unitPriceRule of optionUnitPriceRulesQuery.data?.data ?? []) {
|
|
352
|
-
if (!unitPriceRule.pricingCategoryId)
|
|
353
|
-
continue;
|
|
354
|
-
if (bookingUnitIds.size > 0 && !bookingUnitIds.has(unitPriceRule.unitId))
|
|
355
|
-
continue;
|
|
356
|
-
const existing = unitIdsByCategoryId.get(unitPriceRule.pricingCategoryId) ?? new Set();
|
|
357
|
-
existing.add(unitPriceRule.unitId);
|
|
358
|
-
unitIdsByCategoryId.set(unitPriceRule.pricingCategoryId, existing);
|
|
359
|
-
}
|
|
360
|
-
return Array.from(unitIdsByCategoryId.entries())
|
|
361
|
-
.flatMap(([categoryId, unitIds]) => {
|
|
362
|
-
const category = categoriesById.get(categoryId);
|
|
363
|
-
if (!category)
|
|
364
|
-
return [];
|
|
365
|
-
return [
|
|
366
|
-
{
|
|
367
|
-
categoryId,
|
|
368
|
-
name: category.name,
|
|
369
|
-
code: category.code,
|
|
370
|
-
categoryType: category.categoryType,
|
|
371
|
-
minAge: category.minAge,
|
|
372
|
-
maxAge: category.maxAge,
|
|
373
|
-
unitIds: Array.from(unitIds),
|
|
374
|
-
},
|
|
375
|
-
];
|
|
376
|
-
})
|
|
377
|
-
.sort((left, right) => {
|
|
378
|
-
const leftSort = categoriesById.get(left.categoryId)?.sortOrder ?? 0;
|
|
379
|
-
const rightSort = categoriesById.get(right.categoryId)?.sortOrder ?? 0;
|
|
380
|
-
return leftSort - rightSort || left.name.localeCompare(right.name);
|
|
381
|
-
});
|
|
382
|
-
}, [
|
|
383
|
-
pricingPreview.data,
|
|
384
|
-
pricingCategoriesQuery.data?.data,
|
|
385
|
-
optionUnitPriceRulesQuery.data?.data,
|
|
386
|
-
bookingUnits,
|
|
387
|
-
]);
|
|
388
|
-
const travelerPricingCategoryLabels = React.useMemo(() => Object.fromEntries(travelerPricingCategories.map((category) => [category.categoryId, category.name])), [travelerPricingCategories]);
|
|
389
|
-
const travelerPricingCategoryQuantities = React.useMemo(() => {
|
|
390
|
-
const quantities = {};
|
|
391
|
-
if (travelerPricingCategories.length === 0)
|
|
392
|
-
return quantities;
|
|
393
|
-
for (const traveler of travelers.travelers) {
|
|
394
|
-
if (!traveler.inventoryUnitId)
|
|
395
|
-
continue;
|
|
396
|
-
const pricingCategoryId = inferTravelerPricingCategoryId(traveler, travelerPricingCategories);
|
|
397
|
-
if (!pricingCategoryId)
|
|
398
|
-
continue;
|
|
399
|
-
const unitCategoryQuantities = quantities[traveler.inventoryUnitId] ?? {};
|
|
400
|
-
unitCategoryQuantities[pricingCategoryId] =
|
|
401
|
-
(unitCategoryQuantities[pricingCategoryId] ?? 0) + 1;
|
|
402
|
-
quantities[traveler.inventoryUnitId] = unitCategoryQuantities;
|
|
403
|
-
}
|
|
404
|
-
return quantities;
|
|
405
|
-
}, [travelers.travelers, travelerPricingCategories]);
|
|
406
|
-
// Apply the same draft resolver we use at submit so live pricing
|
|
407
|
-
// and persisted item lines cannot drift. Person-priced options
|
|
408
|
-
// (excursions) derive line quantities from the traveler list;
|
|
409
|
-
// accommodation options preserve operator-picked stepper quantities.
|
|
410
|
-
const displayDraft = React.useMemo(() => resolveBookingDraft({
|
|
411
|
-
quantities: rooms.quantities,
|
|
412
|
-
travelers: travelers.travelers,
|
|
413
|
-
units: bookingUnits,
|
|
414
|
-
}), [rooms.quantities, travelers.travelers, bookingUnits]);
|
|
415
|
-
const displayQuantities = displayDraft.quantities;
|
|
416
|
-
const displayExtraLines = React.useMemo(() => resolveBookingExtraLines({
|
|
417
|
-
extraLines,
|
|
418
|
-
travelerCount: travelers.travelers.length,
|
|
419
|
-
}), [extraLines, travelers.travelers.length]);
|
|
420
|
-
// Currency placeholder used for Travel Credit and payment schedule display.
|
|
421
|
-
// Consumers hooking in real product data should override this by wrapping
|
|
422
|
-
// the component or swapping in their own currency-aware hook.
|
|
423
|
-
const currency = messages.bookingCreateDialog.labels.currency;
|
|
424
|
-
// Source-of-truth currency for the payment-schedule wire payload: prefer
|
|
425
|
-
// the product's own `sellCurrency` (what the server stamps on the new
|
|
426
|
-
// booking via `convertProductToBooking`), fall back to the pricing
|
|
427
|
-
// preview's currency, and only then fall back to the placeholder. The
|
|
428
|
-
// server rejects the create with `invalid_payment_schedules` when any
|
|
429
|
-
// schedule row's currency drifts from the booking's, so this trio has to
|
|
430
|
-
// resolve to the same value the server will pick.
|
|
431
|
-
const bookingProductQuery = useProduct(product.productId || undefined, {
|
|
432
|
-
enabled: enabled && Boolean(product.productId),
|
|
433
|
-
});
|
|
434
|
-
const productSellCurrency = bookingProductQuery.data?.sellCurrency ?? null;
|
|
435
|
-
const pricingCurrency = productSellCurrency ?? pricing?.currency ?? currency;
|
|
436
|
-
const pricingTotalAmountCents = pricing?.confirmedAmountCents ?? undefined;
|
|
437
|
-
const roomUnitLabels = React.useMemo(() => Object.fromEntries(bookingUnits.map((unit) => [unit.optionUnitId, unit.unitName])), [bookingUnits]);
|
|
438
|
-
const createBookingMutation = useBookingCreateMutation();
|
|
439
|
-
const queryClient = useQueryClient();
|
|
440
|
-
// Resolve the billing person/org once at the dialog level so we can
|
|
441
|
-
// snapshot their contact details into the booking row at create time.
|
|
442
|
-
// The booking row's `contact_*` columns are the source of truth for
|
|
443
|
-
// billing on the detail page — the linked CRM record can change (or
|
|
444
|
-
// be deleted) later without retroactively rewriting history.
|
|
445
|
-
const billingPersonRecord = usePerson((person.billTo ?? "person") === "person" ? (person.personId ?? undefined) : undefined, { enabled: (person.billTo ?? "person") === "person" && Boolean(person.personId) }).data;
|
|
446
|
-
const billingOrganizationRecord = useOrganization(person.billTo === "organization" ? (person.organizationId ?? undefined) : undefined, { enabled: person.billTo === "organization" && Boolean(person.organizationId) }).data;
|
|
447
|
-
// Primary address for whichever billing record was picked. Filter by
|
|
448
|
-
// `entityType` + `entityId` to keep the response small; the first
|
|
449
|
-
// address with `isPrimary` wins (the server returns at most one).
|
|
450
|
-
const billingPrimaryAddressKind = (person.billTo ?? "person") === "person" && person.personId
|
|
451
|
-
? "person"
|
|
452
|
-
: person.billTo === "organization" && person.organizationId
|
|
453
|
-
? "organization"
|
|
454
|
-
: null;
|
|
455
|
-
const billingPrimaryAddressEntityId = billingPrimaryAddressKind === "person"
|
|
456
|
-
? (person.personId ?? undefined)
|
|
457
|
-
: billingPrimaryAddressKind === "organization"
|
|
458
|
-
? (person.organizationId ?? undefined)
|
|
459
|
-
: undefined;
|
|
460
|
-
const billingAddressQuery = useAddresses({
|
|
461
|
-
entityType: billingPrimaryAddressKind ?? undefined,
|
|
462
|
-
entityId: billingPrimaryAddressEntityId,
|
|
463
|
-
isPrimary: true,
|
|
464
|
-
limit: 1,
|
|
465
|
-
enabled: Boolean(billingPrimaryAddressKind && billingPrimaryAddressEntityId),
|
|
466
|
-
});
|
|
467
|
-
const billingPrimaryAddress = billingAddressQuery.data?.data?.[0] ?? null;
|
|
468
|
-
const hasSelectedUnits = React.useMemo(() => Object.values(rooms.quantities).some((qty) => qty > 0), [rooms.quantities]);
|
|
469
|
-
const handleSubmit = async () => {
|
|
470
|
-
setError(null);
|
|
471
|
-
setPayloadMismatchUnitIds([]);
|
|
472
|
-
if (!product.productId) {
|
|
473
|
-
setError(messages.bookingCreateDialog.validation.selectProduct);
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
if (!slotId) {
|
|
477
|
-
setError(messages.bookingCreateDialog.validation.selectDeparture);
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
if (!hasSelectedUnits) {
|
|
481
|
-
setError(messages.bookingCreateDialog.validation.selectUnits);
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
let resolvedPersonId = null;
|
|
485
|
-
let resolvedOrganizationId = null;
|
|
486
|
-
if ((person.billTo ?? "person") === "person") {
|
|
487
|
-
if (!person.personId) {
|
|
488
|
-
setError(messages.bookingCreateDialog.validation.selectPerson);
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
resolvedPersonId = person.personId;
|
|
492
|
-
const billingContactValidation = validateBillingPersonContact(billingPersonRecord);
|
|
493
|
-
if (billingContactValidation === "missing-contact") {
|
|
494
|
-
setError(messages.bookingCreateDialog.validation.billingContactRequired);
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
if (billingContactValidation === "invalid-email") {
|
|
498
|
-
setError(messages.bookingCreateDialog.validation.billingEmailInvalid);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
else {
|
|
503
|
-
if (!person.organizationId) {
|
|
504
|
-
setError(messages.bookingCreateDialog.validation.selectOrganization);
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
resolvedOrganizationId = person.organizationId;
|
|
508
|
-
}
|
|
509
|
-
if (travelers.travelers.length === 0) {
|
|
510
|
-
setError(messages.bookingCreateDialog.validation.travelerRequired);
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
const invalidTraveler = travelers.travelers.find((traveler) => (!traveler.personId && (!traveler.firstName.trim() || !traveler.lastName.trim())) ||
|
|
514
|
-
(traveler.email.trim() ? !isRealBookingEmail(traveler.email) : false));
|
|
515
|
-
if (invalidTraveler) {
|
|
516
|
-
setError(messages.bookingCreateDialog.validation.firstAndLastNameRequired);
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
const overCapacity = getOverCapacityInventoryAssignments(bookingUnits, rooms.quantities, travelers.travelers)[0];
|
|
520
|
-
if (overCapacity) {
|
|
521
|
-
setError(formatMessage(messages.bookingCreateDialog.validation.roomCapacityExceeded, {
|
|
522
|
-
room: overCapacity.unitName,
|
|
523
|
-
assigned: overCapacity.assignedTravelers,
|
|
524
|
-
capacity: overCapacity.capacity,
|
|
525
|
-
}));
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
try {
|
|
529
|
-
if (sharedRoom.enabled && sharedRoom.mode === "join" && !sharedRoom.groupId) {
|
|
530
|
-
setError(messages.bookingCreateDialog.validation.selectSharedRoomGroup);
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
const bookingNumber = generateBookingNumber();
|
|
534
|
-
const confirmedSellAmountCents = pricing?.confirmedAmountCents ?? null;
|
|
535
|
-
const catalogSellAmountCents = pricing?.catalogAmountCents ?? null;
|
|
536
|
-
const priceOverrideReason = pricing?.priceOverrideReason.trim() ?? "";
|
|
537
|
-
if (pricing?.requiresReason) {
|
|
538
|
-
setError(messages.bookingCreateDialog.labels.breakdownOverrideReasonRequired);
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
if (findAlreadyPaidInstallmentMissingPaymentDate(paymentSchedule) !== null) {
|
|
542
|
-
setError(messages.bookingCreateDialog.validation.paidPaymentDateRequired);
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
const paymentSchedules = paymentScheduleToRows(paymentSchedule, pricingCurrency, confirmedSellAmountCents);
|
|
546
|
-
// Resolve the draft once, then derive every shape the wire
|
|
547
|
-
// format needs from the result. Person-priced options get
|
|
548
|
-
// per-band quantities (1 adult + 1 child + 1 infant, not
|
|
549
|
-
// "3 x Adult"); accommodation options keep operator-picked
|
|
550
|
-
// room quantities. Server gets `clientLineKey` + `travelerKeys`
|
|
551
|
-
// on each line so it can write `booking_item_travelers` rows.
|
|
552
|
-
const submitUnits = bookingUnits.length > 0
|
|
553
|
-
? bookingUnits
|
|
554
|
-
: getTravelerAssignableStepperUnits((slotUnitAvailability.data?.data ?? []).map((unit) => ({
|
|
555
|
-
...unit,
|
|
556
|
-
optionId: product.optionId,
|
|
557
|
-
})));
|
|
558
|
-
const redistributed = resolveBookingDraft({
|
|
559
|
-
quantities: rooms.quantities,
|
|
560
|
-
travelers: travelers.travelers,
|
|
561
|
-
units: submitUnits,
|
|
562
|
-
});
|
|
563
|
-
const travelerKeysByUnitId = Object.fromEntries(Object.entries(redistributed.travelerIndexesByUnitId).map(([unitId, indexes]) => [
|
|
564
|
-
unitId,
|
|
565
|
-
indexes.every((index) => Boolean(redistributed.travelers[index]?.clientTravelerKey))
|
|
566
|
-
? indexes
|
|
567
|
-
.map((index) => redistributed.travelers[index]?.clientTravelerKey)
|
|
568
|
-
.filter((key) => Boolean(key))
|
|
569
|
-
: [],
|
|
570
|
-
]));
|
|
571
|
-
const travelerIndexesByUnitAndCategoryId = {};
|
|
572
|
-
const travelerKeysByUnitAndCategoryId = {};
|
|
573
|
-
for (const [unitId, indexes] of Object.entries(redistributed.travelerIndexesByUnitId)) {
|
|
574
|
-
for (const index of indexes) {
|
|
575
|
-
const traveler = redistributed.travelers[index];
|
|
576
|
-
if (!traveler)
|
|
577
|
-
continue;
|
|
578
|
-
const pricingCategoryId = inferTravelerPricingCategoryId(traveler, travelerPricingCategories);
|
|
579
|
-
if (!pricingCategoryId)
|
|
580
|
-
continue;
|
|
581
|
-
travelerIndexesByUnitAndCategoryId[unitId] ??= {};
|
|
582
|
-
travelerIndexesByUnitAndCategoryId[unitId][pricingCategoryId] ??= [];
|
|
583
|
-
travelerIndexesByUnitAndCategoryId[unitId][pricingCategoryId].push(index);
|
|
584
|
-
const key = traveler.clientTravelerKey;
|
|
585
|
-
if (key) {
|
|
586
|
-
travelerKeysByUnitAndCategoryId[unitId] ??= {};
|
|
587
|
-
travelerKeysByUnitAndCategoryId[unitId][pricingCategoryId] ??= [];
|
|
588
|
-
travelerKeysByUnitAndCategoryId[unitId][pricingCategoryId].push(key);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
const travelerKeys = redistributed.travelers
|
|
593
|
-
.map((traveler) => traveler.clientTravelerKey)
|
|
594
|
-
.filter((key) => Boolean(key));
|
|
595
|
-
const itemLines = itemLinesToRows(redistributed.quantities, submitUnits, pricing, redistributed.travelerIndexesByUnitId, travelerKeysByUnitId, travelerIndexesByUnitAndCategoryId, travelerKeysByUnitAndCategoryId);
|
|
596
|
-
const resolvedExtraLines = resolveBookingExtraLines({
|
|
597
|
-
extraLines,
|
|
598
|
-
travelerCount: travelers.travelers.length,
|
|
599
|
-
travelerKeys: travelerKeys.length === redistributed.travelers.length ? travelerKeys : undefined,
|
|
600
|
-
});
|
|
601
|
-
const travelerRows = travelersToRows({ travelers: redistributed.travelers });
|
|
602
|
-
const travelCreditRedemption = travelCredit.picked && travelCredit.picked.remainingAmountCents != null
|
|
603
|
-
? {
|
|
604
|
-
travelCreditId: travelCredit.picked.id,
|
|
605
|
-
amountCents: travelCredit.picked.remainingAmountCents,
|
|
606
|
-
}
|
|
607
|
-
: undefined;
|
|
608
|
-
const selectedSharedRoomUnitId = getSelectedSharedRoomUnitId(rooms.quantities);
|
|
609
|
-
const groupMembership = sharedRoom.enabled
|
|
610
|
-
? sharedRoom.mode === "create"
|
|
611
|
-
? {
|
|
612
|
-
action: "create",
|
|
613
|
-
kind: "shared_room",
|
|
614
|
-
label: sharedRoom.groupLabel?.trim() ||
|
|
615
|
-
`${messages.bookingCreateDialog.labels.sharedRoomGeneratedLabelPrefix} - ${bookingNumber}`,
|
|
616
|
-
optionUnitId: selectedSharedRoomUnitId,
|
|
617
|
-
makeBookingPrimary: true,
|
|
618
|
-
}
|
|
619
|
-
: sharedRoom.groupId
|
|
620
|
-
? { action: "join", groupId: sharedRoom.groupId, role: "shared" }
|
|
621
|
-
: undefined
|
|
622
|
-
: undefined;
|
|
623
|
-
// Smart-default status from payment state — any payment marked
|
|
624
|
-
// "Already paid" implies the booking is effectively confirmed,
|
|
625
|
-
// otherwise it lands in `awaiting_payment` so the operator can
|
|
626
|
-
// dispatch a payment link. The server commits this status in
|
|
627
|
-
// the create transaction and emits `booking.confirmed`
|
|
628
|
-
// post-commit when applicable — no second roundtrip.
|
|
629
|
-
const initialStatus = hasAnyPaidPayment(paymentSchedule)
|
|
630
|
-
? "confirmed"
|
|
631
|
-
: "awaiting_payment";
|
|
632
|
-
// Build the billing-contact snapshot from whichever CRM record
|
|
633
|
-
// the operator picked, plus the primary identity address when
|
|
634
|
-
// present. Falls back to nulls when a record is missing — the
|
|
635
|
-
// server stores nulls and the detail page hydrates from the live
|
|
636
|
-
// CRM record at read time.
|
|
637
|
-
const addressSnapshot = billingPrimaryAddress
|
|
638
|
-
? {
|
|
639
|
-
contactAddressLine1: billingPrimaryAddress.line1,
|
|
640
|
-
contactAddressLine2: billingPrimaryAddress.line2,
|
|
641
|
-
contactCity: billingPrimaryAddress.city,
|
|
642
|
-
contactRegion: billingPrimaryAddress.region,
|
|
643
|
-
contactPostalCode: billingPrimaryAddress.postalCode,
|
|
644
|
-
contactCountry: billingPrimaryAddress.country,
|
|
645
|
-
}
|
|
646
|
-
: {};
|
|
647
|
-
const contactSnapshot = billingPersonRecord
|
|
648
|
-
? {
|
|
649
|
-
contactPartyType: "individual",
|
|
650
|
-
contactTaxId: null,
|
|
651
|
-
contactFirstName: billingPersonRecord.firstName,
|
|
652
|
-
contactLastName: billingPersonRecord.lastName,
|
|
653
|
-
contactEmail: billingPersonRecord.email,
|
|
654
|
-
contactPhone: billingPersonRecord.phone,
|
|
655
|
-
contactPreferredLanguage: billingPersonRecord.preferredLanguage,
|
|
656
|
-
...addressSnapshot,
|
|
657
|
-
}
|
|
658
|
-
: billingOrganizationRecord
|
|
659
|
-
? {
|
|
660
|
-
contactPartyType: "company",
|
|
661
|
-
contactTaxId: billingOrganizationRecord.taxId,
|
|
662
|
-
contactFirstName: billingOrganizationRecord.name,
|
|
663
|
-
contactLastName: null,
|
|
664
|
-
contactEmail: null,
|
|
665
|
-
contactPhone: null,
|
|
666
|
-
contactPreferredLanguage: billingOrganizationRecord.preferredLanguage,
|
|
667
|
-
...addressSnapshot,
|
|
668
|
-
}
|
|
669
|
-
: {};
|
|
670
|
-
const { booking } = await createBookingMutation.mutateAsync({
|
|
671
|
-
productId: product.productId,
|
|
672
|
-
bookingNumber,
|
|
673
|
-
optionId: product.optionId,
|
|
674
|
-
slotId,
|
|
675
|
-
personId: resolvedPersonId,
|
|
676
|
-
organizationId: resolvedOrganizationId,
|
|
677
|
-
internalNotes: notes.trim() || null,
|
|
678
|
-
catalogSellAmountCents,
|
|
679
|
-
confirmedSellAmountCents,
|
|
680
|
-
priceOverrideReason: priceOverrideReason || null,
|
|
681
|
-
itemLines: itemLines.length > 0 ? itemLines : undefined,
|
|
682
|
-
extraLines: resolvedExtraLines.length > 0 ? resolvedExtraLines : undefined,
|
|
683
|
-
travelers: travelerRows.length > 0 ? travelerRows : undefined,
|
|
684
|
-
paymentSchedules: paymentSchedules.length > 0 ? paymentSchedules : undefined,
|
|
685
|
-
travelCreditRedemption,
|
|
686
|
-
groupMembership,
|
|
687
|
-
documentGeneration: generateProforma
|
|
688
|
-
? { contractDocument: false, invoiceDocument: true, invoiceType: "proforma" }
|
|
689
|
-
: generateInvoiceAndContract
|
|
690
|
-
? {
|
|
691
|
-
contractDocument: true,
|
|
692
|
-
invoiceDocument: true,
|
|
693
|
-
invoiceType: "invoice",
|
|
694
|
-
}
|
|
695
|
-
: { contractDocument: false, invoiceDocument: false },
|
|
696
|
-
initialStatus,
|
|
697
|
-
// Suppression only matters when transitioning to `confirmed` —
|
|
698
|
-
// `awaiting_payment` doesn't trigger the auto-dispatch
|
|
699
|
-
// subscriber today.
|
|
700
|
-
suppressNotifications: initialStatus === "confirmed" && !notifyTraveler ? true : undefined,
|
|
701
|
-
...contactSnapshot,
|
|
702
|
-
});
|
|
703
|
-
// The booking mutation invalidates booking caches, but the
|
|
704
|
-
// availability surface (slot allocation manifest, slot detail,
|
|
705
|
-
// unit availability, slot list) hosts its own cache under
|
|
706
|
-
// `availabilityQueryKeys.slots()`. Without this, the slot-detail
|
|
707
|
-
// page that opened the sheet keeps showing stale traveler counts /
|
|
708
|
-
// empty rooms / unconsumed capacity until the user refreshes.
|
|
709
|
-
// Nuking the whole slots subtree is cheap and avoids tracking
|
|
710
|
-
// exactly which keys to bust (slotAllocation, slotDetail,
|
|
711
|
-
// slotUnitAvailability, slotsList, …).
|
|
712
|
-
await queryClient.invalidateQueries({ queryKey: availabilityQueryKeys.slots() });
|
|
713
|
-
onCreated?.(booking);
|
|
714
|
-
}
|
|
715
|
-
catch (err) {
|
|
716
|
-
if (err instanceof VoyantApiError && isPayloadResolverMismatchBody(err.body)) {
|
|
717
|
-
setPayloadMismatchUnitIds(Array.from(new Set(err.body.mismatches.map((mismatch) => mismatch.optionUnitId))));
|
|
718
|
-
setError(formatPayloadResolverMismatchError(err.body, roomUnitLabels, messages.bookingCreateDialog.validation));
|
|
719
|
-
return;
|
|
720
|
-
}
|
|
721
|
-
setError(err instanceof Error ? err.message : messages.bookingCreateDialog.validation.createFailed);
|
|
722
|
-
}
|
|
723
|
-
};
|
|
724
|
-
const isSubmitting = createBookingMutation.isPending;
|
|
725
|
-
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) => {
|
|
726
|
-
setPayloadMismatchUnitIds([]);
|
|
727
|
-
setProduct(next);
|
|
728
|
-
}, enabled: enabled, lockProduct: Boolean(defaultProductId || defaultSlotId), labels: {
|
|
729
|
-
optionNone: messages.bookingCreateDialog.labels.noSpecificOption,
|
|
730
|
-
}, 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) => {
|
|
731
|
-
setPayloadMismatchUnitIds([]);
|
|
732
|
-
setRooms(next);
|
|
733
|
-
}, productId: product.productId, slotId: slotId, optionId: product.optionId, enabled: enabled, onUnitsChange: handleRoomUnitsChange, slotHasFiniteCapacity: Boolean(selectedSlot) &&
|
|
734
|
-
!selectedSlot?.unlimited &&
|
|
735
|
-
typeof selectedSlot?.remainingPax === "number", invalidOptionUnitIds: payloadMismatchUnitIds, labels: {
|
|
736
|
-
heading: messages.bookingCreateDialog.labels.roomsHeading,
|
|
737
|
-
noOption: messages.bookingCreateDialog.labels.roomsNoOption,
|
|
738
|
-
noSlot: messages.bookingCreateDialog.labels.roomsNoSlot,
|
|
739
|
-
noUnits: messages.bookingCreateDialog.labels.roomsNoUnits,
|
|
740
|
-
remaining: messages.bookingCreateDialog.labels.roomsRemaining,
|
|
741
|
-
unlimited: messages.bookingCreateDialog.labels.roomsUnlimited,
|
|
742
|
-
} })) : null, product.productId && slotId ? (_jsx(ProductExtrasPickerSection, { productId: product.productId, optionId: product.optionId, currency: pricingCurrency, travelerCount: travelers.travelers.length, value: extraLines, onChange: setExtraLines, enabled: enabled, labels: {
|
|
743
|
-
heading: messages.bookingCreateDialog.labels.extrasHeading,
|
|
744
|
-
empty: messages.bookingCreateDialog.labels.extrasEmpty,
|
|
745
|
-
included: messages.bookingCreateDialog.labels.extrasIncluded,
|
|
746
|
-
onRequest: messages.bookingCreateDialog.labels.extrasOnRequest,
|
|
747
|
-
perPerson: messages.bookingCreateDialog.labels.extrasPerPerson,
|
|
748
|
-
} })) : null, product.productId && slotId ? (_jsxs("div", { className: "flex flex-col gap-2 rounded-md border p-3", children: [_jsx(Label, { children: messages.bookingCreateDialog.labels.billingHeading }), _jsx(PersonPickerSection, { value: person, onChange: setPerson, enabled: enabled, labels: {
|
|
749
|
-
createNewPerson: messages.bookingCreateDialog.labels.createNewPerson,
|
|
750
|
-
selectExistingPerson: messages.bookingCreateDialog.labels.selectExistingPerson,
|
|
751
|
-
organizationNone: messages.bookingCreateDialog.labels.organizationNone,
|
|
752
|
-
} })] })) : null, product.productId && slotId ? (_jsx(SharedRoomSection, { value: sharedRoom, onChange: setSharedRoom, productId: product.productId || undefined, enabled: enabled, labels: {
|
|
753
|
-
toggle: messages.bookingCreateDialog.labels.sharedRoomToggle,
|
|
754
|
-
createMode: messages.bookingCreateDialog.labels.sharedRoomCreateMode,
|
|
755
|
-
joinMode: messages.bookingCreateDialog.labels.sharedRoomJoinMode,
|
|
756
|
-
selectPlaceholder: messages.bookingCreateDialog.labels.sharedRoomSelectPlaceholder,
|
|
757
|
-
noGroups: messages.bookingCreateDialog.labels.sharedRoomNoGroups,
|
|
758
|
-
createHint: messages.bookingCreateDialog.labels.sharedRoomCreateHint,
|
|
759
|
-
remove: messages.bookingCreateDialog.labels.sharedRoomRemove,
|
|
760
|
-
} })) : null, product.productId && slotId ? (_jsx(TravelersSection, { value: travelers, onChange: (next) => {
|
|
761
|
-
setPayloadMismatchUnitIds([]);
|
|
762
|
-
setTravelers(next);
|
|
763
|
-
}, roomUnits: roomUnitOptions.length > 0 ? roomUnitOptions : undefined, roomGroups: roomGroups.length > 0 ? roomGroups : undefined, pricingCategories: hasRoomPricingMatrix || roomUnitOptions.length > 0
|
|
764
|
-
? travelerPricingCategories
|
|
765
|
-
: undefined, billingPersonId: (person.billTo ?? "person") === "person" ? person.personId : null, labels: {
|
|
766
|
-
heading: messages.bookingCreateDialog.labels.travelerHeading,
|
|
767
|
-
addTraveler: messages.bookingCreateDialog.labels.addTraveler,
|
|
768
|
-
person: messages.bookingCreateDialog.labels.travelerPerson,
|
|
769
|
-
personSearchPlaceholder: messages.bookingCreateDialog.labels.travelerPersonSearchPlaceholder,
|
|
770
|
-
personEmpty: messages.bookingCreateDialog.labels.travelerPersonEmpty,
|
|
771
|
-
createNewPerson: messages.bookingCreateDialog.labels.createNewPerson,
|
|
772
|
-
createPersonSheetTitle: messages.bookingCreateDialog.labels.createPersonSheetTitle,
|
|
773
|
-
addBillingPerson: messages.bookingCreateDialog.labels.addBillingPersonAsTraveler,
|
|
774
|
-
role: messages.bookingCreateDialog.labels.travelerRole,
|
|
775
|
-
roleLead: messages.bookingCreateDialog.labels.travelerLead,
|
|
776
|
-
roleAdult: messages.bookingCreateDialog.labels.travelerAdult,
|
|
777
|
-
roleChild: messages.bookingCreateDialog.labels.travelerChild,
|
|
778
|
-
roleInfant: messages.bookingCreateDialog.labels.travelerInfant,
|
|
779
|
-
room: messages.bookingCreateDialog.labels.travelerRoom,
|
|
780
|
-
noRoom: messages.bookingCreateDialog.labels.travelerNoRoom,
|
|
781
|
-
remove: messages.bookingCreateDialog.labels.travelerRemove,
|
|
782
|
-
empty: messages.bookingCreateDialog.labels.travelerEmpty,
|
|
783
|
-
} })) : null, product.productId && slotId ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: messages.bookingCreateDialog.fields.internalNotes }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), placeholder: messages.bookingCreateDialog.placeholders.internalNotes })] })) : null, product.productId && slotId ? (_jsxs("div", { className: "flex flex-col gap-3 rounded-md border p-3", children: [_jsx(Label, { children: messages.bookingCreateDialog.labels.documentGenerationHeading }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "new-booking-generate-proforma", checked: generateProforma, onCheckedChange: (value) => setGenerateProforma(value === true) }), _jsx(Label, { htmlFor: "new-booking-generate-proforma", className: "cursor-pointer", children: messages.bookingCreateDialog.labels.generateProforma })] }), _jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "new-booking-generate-invoice-and-contract", checked: generateInvoiceAndContract, onCheckedChange: (value) => setGenerateInvoiceAndContract(value === true) }), _jsx(Label, { htmlFor: "new-booking-generate-invoice-and-contract", className: "cursor-pointer", children: messages.bookingCreateDialog.labels.generateInvoiceAndContract })] }), hasAnyPaidPayment(paymentSchedule) ? (_jsxs("div", { className: "flex items-start gap-2 border-t pt-2 text-sm", children: [_jsx(Checkbox, { id: "new-booking-notify-traveler", checked: notifyTraveler, onCheckedChange: (v) => setNotifyTraveler(v === true), className: "mt-0.5" }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { htmlFor: "new-booking-notify-traveler", className: "cursor-pointer text-sm", children: messages.bookingCreateDialog.fields.notifyTraveler }), _jsx("p", { className: "text-xs text-muted-foreground", children: messages.bookingCreateDialog.fields.notifyTravelerHint })] })] })) : null] })] })) : null] }), error ? (_jsx("div", { role: "alert", className: "mt-3 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive", children: error })) : null, _jsxs("div", { className: "mt-4 flex items-center justify-end gap-2 border-t px-1 pt-3", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, disabled: isSubmitting, children: messages.common.cancel }), _jsxs(Button, { type: "button", size: "sm", onClick: handleSubmit, disabled: isSubmitting || !product.productId || !slotId || !hasSelectedUnits, children: [isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), hasAnyPaidPayment(paymentSchedule)
|
|
784
|
-
? messages.bookingCreateDialog.actions.createConfirmedBooking
|
|
785
|
-
: 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: (() => {
|
|
786
|
-
const slot = slots.find((s) => s.id === slotId);
|
|
787
|
-
return slot ? formatSlotLabel(slot) : null;
|
|
788
|
-
})(), unitQuantities: displayQuantities, unitLabels: roomUnitLabels, pricingCategoryQuantities: travelerPricingCategoryQuantities, pricingCategoryLabels: travelerPricingCategoryLabels, extraLines: displayExtraLines, travelers: travelers.travelers, messages: messages, onPricingChange: setPricing }), product.productId && slotId ? (_jsx(TravelCreditPickerSection, { value: travelCredit, onChange: setTravelCredit, currency: currency, labels: {
|
|
789
|
-
heading: messages.bookingCreateDialog.labels.travelCreditHeading,
|
|
790
|
-
codePlaceholder: messages.bookingCreateDialog.labels.travelCreditCodePlaceholder,
|
|
791
|
-
apply: messages.bookingCreateDialog.labels.travelCreditApply,
|
|
792
|
-
clear: messages.bookingCreateDialog.labels.travelCreditClear,
|
|
793
|
-
remainingLabel: messages.bookingCreateDialog.labels.travelCreditRemainingLabel,
|
|
794
|
-
invalidLabel: messages.bookingCreateDialog.labels.travelCreditInvalidLabel,
|
|
795
|
-
} })) : null, product.productId && slotId ? (_jsx(PaymentScheduleSection, { value: paymentSchedule, onChange: setPaymentSchedule, currency: pricingCurrency, totalAmountCents: pricingTotalAmountCents, departureDate: selectedSlot?.startsAt?.slice(0, 10) ?? null, labels: {
|
|
796
|
-
heading: messages.bookingCreateDialog.labels.paymentHeading,
|
|
797
|
-
modeUnpaid: messages.bookingCreateDialog.labels.paymentModeUnpaid,
|
|
798
|
-
modeFull: messages.bookingCreateDialog.labels.paymentModeFull,
|
|
799
|
-
modeAdvance: messages.bookingCreateDialog.labels.paymentModeAdvance,
|
|
800
|
-
modeSplit: messages.bookingCreateDialog.labels.paymentModeSplit,
|
|
801
|
-
dueDate: messages.bookingCreateDialog.labels.paymentDueDate,
|
|
802
|
-
amount: messages.bookingCreateDialog.labels.paymentAmount,
|
|
803
|
-
firstInstallment: messages.bookingCreateDialog.labels.paymentFirstInstallment,
|
|
804
|
-
secondInstallment: messages.bookingCreateDialog.labels.paymentSecondInstallment,
|
|
805
|
-
preset5050: messages.bookingCreateDialog.labels.paymentPreset5050,
|
|
806
|
-
unpaidHint: messages.bookingCreateDialog.labels.paymentUnpaidHint,
|
|
807
|
-
totalDue: messages.bookingCreateDialog.labels.paymentTotalDue,
|
|
808
|
-
scheduledTotal: messages.bookingCreateDialog.labels.paymentScheduledTotal,
|
|
809
|
-
remaining: messages.bookingCreateDialog.labels.paymentRemaining,
|
|
810
|
-
alreadyPaid: messages.bookingCreateDialog.labels.paymentAlreadyPaid,
|
|
811
|
-
paymentDate: messages.bookingCreateDialog.labels.paymentDate,
|
|
812
|
-
paymentMethod: messages.bookingCreateDialog.labels.paymentMethod,
|
|
813
|
-
paymentReference: messages.bookingCreateDialog.labels.paymentReference,
|
|
814
|
-
} })) : null] })] }));
|
|
815
|
-
}
|