@voyantjs/travel-composer-react 0.105.6 → 0.105.8
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/dist/admin/admin-trip-composer-page.d.ts +6 -0
- package/dist/admin/admin-trip-composer-page.d.ts.map +1 -0
- package/dist/admin/admin-trip-composer-page.js +793 -0
- package/dist/admin/admin-trip-composer-panels.d.ts +180 -0
- package/dist/admin/admin-trip-composer-panels.d.ts.map +1 -0
- package/dist/admin/admin-trip-composer-panels.js +1372 -0
- package/dist/admin/index.d.ts +63 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +119 -0
- package/dist/admin/pages/trip-detail-page.d.ts +10 -0
- package/dist/admin/pages/trip-detail-page.d.ts.map +1 -0
- package/dist/admin/pages/trip-detail-page.js +12 -0
- package/dist/admin/trip-component-display.d.ts +10 -0
- package/dist/admin/trip-component-display.d.ts.map +1 -0
- package/dist/admin/trip-component-display.js +137 -0
- package/dist/admin/trip-detail-host.d.ts +12 -0
- package/dist/admin/trip-detail-host.d.ts.map +1 -0
- package/dist/admin/trip-detail-host.js +257 -0
- package/dist/admin/trip-list-filters.d.ts +33 -0
- package/dist/admin/trip-list-filters.d.ts.map +1 -0
- package/dist/admin/trip-list-filters.js +94 -0
- package/dist/admin/trips-host.d.ts +15 -0
- package/dist/admin/trips-host.d.ts.map +1 -0
- package/dist/admin/trips-host.js +232 -0
- package/package.json +59 -3
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useMutation } from "@tanstack/react-query";
|
|
4
|
+
import { useOperatorAdminMessages as useAdminMessages, useAdminNavigate } from "@voyantjs/admin";
|
|
5
|
+
import { emptyPersonPickerValue } from "@voyantjs/bookings-react/components/person-picker-section";
|
|
6
|
+
import { emptyVoucherPickerValue } from "@voyantjs/bookings-react/components/voucher-picker-section";
|
|
7
|
+
import { PersonPickerSection, } from "@voyantjs/bookings-react/ui";
|
|
8
|
+
import { usePerson } from "@voyantjs/crm-react";
|
|
9
|
+
import { formatMessage } from "@voyantjs/i18n";
|
|
10
|
+
import { Button } from "@voyantjs/ui/components/button";
|
|
11
|
+
import { Checkbox } from "@voyantjs/ui/components/checkbox";
|
|
12
|
+
import { CurrencyCombobox } from "@voyantjs/ui/components/currency-combobox";
|
|
13
|
+
import { Label } from "@voyantjs/ui/components/label";
|
|
14
|
+
import { Textarea } from "@voyantjs/ui/components/textarea";
|
|
15
|
+
import { AlertTriangle, Loader2 } from "lucide-react";
|
|
16
|
+
import { useEffect, useMemo, useState } from "react";
|
|
17
|
+
import { addTripComponent, createTrip, getTrip, previewTripCancellation, priceTrip, removeTripComponent, reserveTrip, startTripCheckout, updateTripComponent, } from "../operations.js";
|
|
18
|
+
import { useVoyantTravelComposerContext } from "../provider.js";
|
|
19
|
+
import { AddComponentMenu, CommittedComponentCard, ComponentsEmpty, componentTitleFor, computePlaceholderTotals, Field, findOverlappingComponent, flightPricingFromPending, newPendingComponent, PendingComponentCard, PrimaryAction, Section, StatusAlert, sortComponentsBySchedule, TripPreviewRail, TripTravelersSection, } from "./admin-trip-composer-panels.js";
|
|
20
|
+
const defaultPaymentCurrency = "EUR";
|
|
21
|
+
export function AdminTripComposerPage({ initialTrip = null, }) {
|
|
22
|
+
const navigateTo = useAdminNavigate();
|
|
23
|
+
const t = useAdminMessages().trips.adminComposer;
|
|
24
|
+
const [state, setState] = useState({
|
|
25
|
+
trip: initialTrip,
|
|
26
|
+
message: null,
|
|
27
|
+
error: null,
|
|
28
|
+
});
|
|
29
|
+
const [billing, setBilling] = useState(emptyPersonPickerValue);
|
|
30
|
+
const [travelers, setTravelers] = useState([]);
|
|
31
|
+
const [notes, setNotes] = useState("");
|
|
32
|
+
const [pending, setPending] = useState([]);
|
|
33
|
+
const [committingLocalId, setCommittingLocalId] = useState(null);
|
|
34
|
+
const [voucher, setVoucher] = useState(emptyVoucherPickerValue);
|
|
35
|
+
const [createAsDraft, setCreateAsDraft] = useState(false);
|
|
36
|
+
const [paymentCurrency, setPaymentCurrency] = useState(defaultPaymentCurrency);
|
|
37
|
+
const [selectedCancellationIds, setSelectedCancellationIds] = useState([]);
|
|
38
|
+
const [cancellationReason, setCancellationReason] = useState(t.cancellation.defaultReason);
|
|
39
|
+
const [cancellationPreview, setCancellationPreview] = useState(null);
|
|
40
|
+
const { baseUrl, fetcher } = useVoyantTravelComposerContext();
|
|
41
|
+
const client = useMemo(() => ({ baseUrl, fetcher }), [baseUrl, fetcher]);
|
|
42
|
+
const trip = state.trip;
|
|
43
|
+
const envelopeId = trip?.envelope.id;
|
|
44
|
+
const components = useMemo(() => sortComponentsBySchedule((trip?.components ?? []).filter((component) => component.status !== "removed")), [trip?.components]);
|
|
45
|
+
const selectedCount = selectedCancellationIds.length;
|
|
46
|
+
const envelopeStatus = trip?.envelope.status;
|
|
47
|
+
// Once the trip is reserved or further along, removal touches real holds /
|
|
48
|
+
// bookings — operators must go through cancellation preview. Before that
|
|
49
|
+
// (`draft` / priced) a component is a no-op blueprint and can be deleted.
|
|
50
|
+
const trapReserved = envelopeStatus === "reserved" ||
|
|
51
|
+
envelopeStatus === "reserve_in_progress" ||
|
|
52
|
+
envelopeStatus === "checkout_started" ||
|
|
53
|
+
envelopeStatus === "booked";
|
|
54
|
+
const billingPersonQuery = usePerson(billing.personId || undefined, {
|
|
55
|
+
enabled: billing.mode === "existing" && Boolean(billing.personId),
|
|
56
|
+
});
|
|
57
|
+
const payerName = derivePayerName(billing, billingPersonQuery.data, t);
|
|
58
|
+
const payerEmail = derivePayerEmail(billing, billingPersonQuery.data);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setState((current) => ({ ...current, trip: initialTrip }));
|
|
61
|
+
const travelerParty = initialTrip?.envelope.travelerParty;
|
|
62
|
+
if (!travelerParty) {
|
|
63
|
+
setBilling(emptyPersonPickerValue);
|
|
64
|
+
setTravelers([]);
|
|
65
|
+
setNotes("");
|
|
66
|
+
setVoucher(emptyVoucherPickerValue);
|
|
67
|
+
setCreateAsDraft(false);
|
|
68
|
+
setPaymentCurrency(defaultPaymentCurrency);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
setBilling(hydrateBilling(travelerParty));
|
|
72
|
+
setTravelers(hydrateTravelers(travelerParty));
|
|
73
|
+
setNotes(initialTrip.envelope.description ?? "");
|
|
74
|
+
setVoucher(hydrateVoucher(travelerParty));
|
|
75
|
+
const constraints = initialTrip.envelope.constraints;
|
|
76
|
+
setCreateAsDraft(booleanFromRecord(constraints, "createAsDraft"));
|
|
77
|
+
setPaymentCurrency(stringFromRecord(constraints, "paymentCurrency") ||
|
|
78
|
+
initialTrip.envelope.aggregateCurrency ||
|
|
79
|
+
defaultPaymentCurrency);
|
|
80
|
+
}, [initialTrip]);
|
|
81
|
+
function showError(error) {
|
|
82
|
+
setState((current) => ({ ...current, error: apiError(error, t), message: null }));
|
|
83
|
+
}
|
|
84
|
+
async function ensureTrip() {
|
|
85
|
+
if (state.trip)
|
|
86
|
+
return state.trip;
|
|
87
|
+
assertTripCreationRequirements({ billing, travelers, payerName, payerEmail }, t);
|
|
88
|
+
const created = await createTrip(client, {
|
|
89
|
+
description: notes || undefined,
|
|
90
|
+
travelerParty: {
|
|
91
|
+
billing: serializeBilling(billing, payerName, payerEmail),
|
|
92
|
+
travelers,
|
|
93
|
+
voucher: voucher.picked
|
|
94
|
+
? {
|
|
95
|
+
id: voucher.picked.id,
|
|
96
|
+
code: voucher.picked.code,
|
|
97
|
+
currency: voucher.picked.currency,
|
|
98
|
+
remainingAmountCents: voucher.picked.remainingAmountCents,
|
|
99
|
+
}
|
|
100
|
+
: null,
|
|
101
|
+
},
|
|
102
|
+
constraints: {
|
|
103
|
+
channel: "admin-composer",
|
|
104
|
+
compositionMode: "cross-vertical",
|
|
105
|
+
createAsDraft,
|
|
106
|
+
paymentCurrency,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
setState((current) => ({ ...current, trip: created }));
|
|
110
|
+
return created;
|
|
111
|
+
}
|
|
112
|
+
const commitMutation = useMutation({
|
|
113
|
+
mutationFn: async (component) => {
|
|
114
|
+
const currentTrip = await ensureTrip();
|
|
115
|
+
const input = pendingToAddInput(component, {
|
|
116
|
+
billing,
|
|
117
|
+
travelers,
|
|
118
|
+
payerName,
|
|
119
|
+
payerEmail,
|
|
120
|
+
paymentCurrency,
|
|
121
|
+
}, t);
|
|
122
|
+
if (!input)
|
|
123
|
+
throw new Error(t.errors.componentNotReady);
|
|
124
|
+
await addTripComponent(client, currentTrip.envelope.id, input);
|
|
125
|
+
return priceTrip(client, currentTrip.envelope.id, {
|
|
126
|
+
scope: { locale: "en-US", audience: "staff", market: "default", currency: paymentCurrency },
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
onSuccess: (result, component) => {
|
|
130
|
+
setPending((current) => current.filter((p) => p.localId !== component.localId));
|
|
131
|
+
setState({
|
|
132
|
+
trip: { envelope: result.envelope, components: result.components },
|
|
133
|
+
message: t.statusMessages.componentAddedAndPriced,
|
|
134
|
+
error: failuresToString(result.failures, t),
|
|
135
|
+
});
|
|
136
|
+
setCancellationPreview(null);
|
|
137
|
+
setCommittingLocalId(null);
|
|
138
|
+
},
|
|
139
|
+
onError: (error, component) => {
|
|
140
|
+
const message = apiError(error, t);
|
|
141
|
+
setPending((current) => current.map((p) => (p.localId === component.localId ? { ...p, commitError: message } : p)));
|
|
142
|
+
setCommittingLocalId(null);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const reserveMutation = useMutation({
|
|
146
|
+
mutationFn: async () => {
|
|
147
|
+
if (!envelopeId)
|
|
148
|
+
throw new Error(t.errors.priceTripFirst);
|
|
149
|
+
assertTripCreationRequirements({ billing, travelers, payerName, payerEmail }, t);
|
|
150
|
+
const reserved = await reserveTrip(client, envelopeId, {
|
|
151
|
+
idempotencyKey: `admin-reserve-${envelopeId}`,
|
|
152
|
+
refreshScope: {
|
|
153
|
+
locale: "en-US",
|
|
154
|
+
audience: "staff",
|
|
155
|
+
market: "default",
|
|
156
|
+
currency: paymentCurrency,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
if (reserved.failures.length > 0)
|
|
160
|
+
return { reserved, checkout: null };
|
|
161
|
+
const checkout = await startTripCheckout(client, envelopeId, {
|
|
162
|
+
idempotencyKey: `admin-checkout-${envelopeId}`,
|
|
163
|
+
intent: "card",
|
|
164
|
+
request: { initiatedBy: "admin-trip-composer", collectionCurrency: paymentCurrency },
|
|
165
|
+
});
|
|
166
|
+
return { reserved, checkout };
|
|
167
|
+
},
|
|
168
|
+
onSuccess: ({ reserved, checkout }) => {
|
|
169
|
+
const trip = checkout
|
|
170
|
+
? { envelope: checkout.envelope, components: checkout.components }
|
|
171
|
+
: { envelope: reserved.envelope, components: reserved.components };
|
|
172
|
+
setState({
|
|
173
|
+
trip,
|
|
174
|
+
message: checkout?.target.paymentSessionId
|
|
175
|
+
? t.statusMessages.tripReservedWithLink
|
|
176
|
+
: t.statusMessages.tripReserved,
|
|
177
|
+
error: failuresToString(reserved.failures, t),
|
|
178
|
+
});
|
|
179
|
+
// Keep operators in the trip aggregate after reserve; individual booking
|
|
180
|
+
// links remain available from each component card.
|
|
181
|
+
if (reserved.failures.length === 0) {
|
|
182
|
+
navigateTo("trip.detail", { tripId: reserved.envelope.id });
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
onError: (error) => showError(error),
|
|
186
|
+
});
|
|
187
|
+
const removeComponentMutation = useMutation({
|
|
188
|
+
mutationFn: async (componentId) => {
|
|
189
|
+
if (!envelopeId)
|
|
190
|
+
throw new Error(t.errors.noTrip);
|
|
191
|
+
await removeTripComponent(client, componentId);
|
|
192
|
+
return getTrip(client, envelopeId);
|
|
193
|
+
},
|
|
194
|
+
onMutate: (componentId) => {
|
|
195
|
+
const previousTrip = state.trip;
|
|
196
|
+
// Optimistically mark the component as removed so the card disappears
|
|
197
|
+
// immediately. Aggregate totals re-derive from the visible components.
|
|
198
|
+
if (previousTrip) {
|
|
199
|
+
setState((current) => ({
|
|
200
|
+
...current,
|
|
201
|
+
trip: {
|
|
202
|
+
...previousTrip,
|
|
203
|
+
components: previousTrip.components.map((component) => component.id === componentId
|
|
204
|
+
? { ...component, status: "removed" }
|
|
205
|
+
: component),
|
|
206
|
+
},
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
setSelectedCancellationIds((current) => current.filter((id) => id !== componentId));
|
|
210
|
+
setCancellationPreview(null);
|
|
211
|
+
return { previousTrip };
|
|
212
|
+
},
|
|
213
|
+
onSuccess: (updatedTrip) => {
|
|
214
|
+
setState({ trip: updatedTrip, message: t.statusMessages.componentRemoved, error: null });
|
|
215
|
+
},
|
|
216
|
+
onError: (error, _componentId, context) => {
|
|
217
|
+
if (context?.previousTrip) {
|
|
218
|
+
setState((current) => ({ ...current, trip: context.previousTrip }));
|
|
219
|
+
}
|
|
220
|
+
showError(error);
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const updateComponentSetupMutation = useMutation({
|
|
224
|
+
mutationFn: async ({ componentId, metadata, }) => updateTripComponent(client, componentId, { metadata }),
|
|
225
|
+
onMutate: ({ componentId, metadata }) => {
|
|
226
|
+
const previousTrip = state.trip;
|
|
227
|
+
if (previousTrip) {
|
|
228
|
+
setState((current) => ({
|
|
229
|
+
...current,
|
|
230
|
+
trip: {
|
|
231
|
+
...previousTrip,
|
|
232
|
+
components: previousTrip.components.map((component) => component.id === componentId ? { ...component, metadata } : component),
|
|
233
|
+
},
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
return { previousTrip };
|
|
237
|
+
},
|
|
238
|
+
onSuccess: (updatedComponent) => {
|
|
239
|
+
setState((current) => current.trip
|
|
240
|
+
? {
|
|
241
|
+
...current,
|
|
242
|
+
trip: {
|
|
243
|
+
...current.trip,
|
|
244
|
+
components: current.trip.components.map((component) => component.id === updatedComponent.id ? updatedComponent : component),
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
: current);
|
|
248
|
+
},
|
|
249
|
+
onError: (error, _input, context) => {
|
|
250
|
+
if (context?.previousTrip) {
|
|
251
|
+
setState((current) => ({ ...current, trip: context.previousTrip }));
|
|
252
|
+
}
|
|
253
|
+
showError(error);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
const cancellationMutation = useMutation({
|
|
257
|
+
mutationFn: async () => {
|
|
258
|
+
if (!envelopeId)
|
|
259
|
+
throw new Error(t.errors.noTripToCancel);
|
|
260
|
+
return previewTripCancellation(client, envelopeId, {
|
|
261
|
+
componentIds: selectedCancellationIds,
|
|
262
|
+
reason: cancellationReason,
|
|
263
|
+
request: { initiatedBy: "admin" },
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
onSuccess: (result) => {
|
|
267
|
+
setCancellationPreview({
|
|
268
|
+
refund: result.preview.estimatedRefundAmountCents,
|
|
269
|
+
penalty: result.preview.estimatedPenaltyAmountCents,
|
|
270
|
+
staffActionRequired: result.preview.staffActionRequired,
|
|
271
|
+
warnings: result.preview.warnings,
|
|
272
|
+
});
|
|
273
|
+
setState({
|
|
274
|
+
trip: { envelope: result.envelope, components: result.components },
|
|
275
|
+
message: t.statusMessages.cancellationPreviewReady,
|
|
276
|
+
error: null,
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
onError: (error) => showError(error),
|
|
280
|
+
});
|
|
281
|
+
const isBusy = commitMutation.isPending ||
|
|
282
|
+
reserveMutation.isPending ||
|
|
283
|
+
cancellationMutation.isPending ||
|
|
284
|
+
removeComponentMutation.isPending ||
|
|
285
|
+
updateComponentSetupMutation.isPending;
|
|
286
|
+
function handleAddVertical(kind) {
|
|
287
|
+
setPending((current) => [...current, newPendingComponent(kind)]);
|
|
288
|
+
}
|
|
289
|
+
function updatePending(next) {
|
|
290
|
+
setPending((current) => current.map((p) => (p.localId === next.localId ? next : p)));
|
|
291
|
+
}
|
|
292
|
+
function removePending(localId) {
|
|
293
|
+
setPending((current) => current.filter((p) => p.localId !== localId));
|
|
294
|
+
}
|
|
295
|
+
function toggleCancellationSelection(componentId, checked) {
|
|
296
|
+
setCancellationPreview(null);
|
|
297
|
+
setSelectedCancellationIds((current) => checked
|
|
298
|
+
? [...new Set([...current, componentId])]
|
|
299
|
+
: current.filter((id) => id !== componentId));
|
|
300
|
+
}
|
|
301
|
+
function updateComponentBookingSetup(component, setup) {
|
|
302
|
+
const metadata = metadataWithComponentBookingSetup(component, setup);
|
|
303
|
+
updateComponentSetupMutation.mutate({ componentId: component.id, metadata });
|
|
304
|
+
}
|
|
305
|
+
function commitPending(component) {
|
|
306
|
+
const overlap = findOverlappingComponent(component, components);
|
|
307
|
+
if (overlap) {
|
|
308
|
+
setPending((current) => current.map((p) => p.localId === component.localId
|
|
309
|
+
? {
|
|
310
|
+
...p,
|
|
311
|
+
commitError: `These dates overlap with "${componentTitleFor(overlap)}". Adjust the schedule before adding to the trip.`,
|
|
312
|
+
}
|
|
313
|
+
: p));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
setCommittingLocalId(component.localId);
|
|
317
|
+
setPending((current) => current.map((p) => (p.localId === component.localId ? { ...p, commitError: null } : p)));
|
|
318
|
+
commitMutation.mutate(component);
|
|
319
|
+
}
|
|
320
|
+
return (_jsxs("main", { className: "mx-auto flex w-full max-w-screen-2xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8", children: [_jsxs("header", { className: "flex flex-col gap-1", children: [_jsx("h1", { className: "font-semibold text-2xl tracking-tight", children: t.heading }), _jsx("p", { className: "text-muted-foreground text-sm", children: t.subheading })] }), state.error ? (_jsx(StatusAlert, { title: t.requestFailed, message: state.error, tone: "error" })) : null, _jsxs("div", { className: "grid min-h-0 flex-1 gap-6 lg:grid-cols-12", children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-4 lg:col-span-8", children: [_jsx(Section, { title: t.billingSectionTitle, children: _jsx(PersonPickerSection, { value: billing, onChange: setBilling }) }), _jsx(TripTravelersSection, { value: travelers, onChange: setTravelers, billingPersonId: billing.mode === "existing" ? billing.personId || null : null }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "font-medium text-base", children: t.itinerarySectionTitle }), components.length === 0 && pending.length === 0 ? _jsx(ComponentsEmpty, {}) : null, components.map((component, index) => (_jsx(CommittedComponentCard, { component: component, index: index, selectable: trapReserved, selected: selectedCancellationIds.includes(component.id), onSelectedChange: (checked) => toggleCancellationSelection(component.id, checked), onRemove: trapReserved ? undefined : () => removeComponentMutation.mutate(component.id), removePending: removeComponentMutation.isPending &&
|
|
321
|
+
removeComponentMutation.variables === component.id, bookingSetupEditable: !trapReserved, bookingSetupSaving: updateComponentSetupMutation.isPending &&
|
|
322
|
+
updateComponentSetupMutation.variables?.componentId === component.id, onBookingSetupChange: updateComponentBookingSetup }, component.id))), pending.map((entry) => (_jsx(PendingComponentCard, { pending: entry, onChange: updatePending, onRemove: () => removePending(entry.localId), onCommit: () => commitPending(entry), committing: committingLocalId === entry.localId && commitMutation.isPending, travelers: travelers }, entry.localId))), _jsx(AddComponentMenu, { onAdd: handleAddVertical, disabled: isBusy })] }), trapReserved && selectedCount > 0 ? (_jsxs(Section, { title: formatMessage(selectedCount === 1
|
|
323
|
+
? t.cancellation.sectionTitleSingular
|
|
324
|
+
: t.cancellation.sectionTitlePlural, { count: selectedCount }), description: t.cancellation.sectionDescription, action: _jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
|
|
325
|
+
setSelectedCancellationIds([]);
|
|
326
|
+
setCancellationPreview(null);
|
|
327
|
+
}, children: t.cancellation.clearSelection }), children: [_jsx(Field, { label: t.cancellation.reasonLabel, children: _jsx(Textarea, { rows: 2, value: cancellationReason, onChange: (event) => setCancellationReason(event.target.value) }) }), _jsxs(Button, { variant: "outline", onClick: () => cancellationMutation.mutate(), disabled: isBusy || !envelopeId, children: [cancellationMutation.isPending ? (_jsx(Loader2, { className: "size-4 animate-spin" })) : (_jsx(AlertTriangle, { className: "size-4" })), t.cancellation.previewButton] }), cancellationPreview ? (_jsxs("div", { className: "space-y-1 rounded-md border bg-muted/30 p-3 text-sm", children: [_jsx(CancellationRow, { label: t.cancellation.estimatedRefund, value: formatMoney(cancellationPreview.refund, paymentCurrency) }), _jsx(CancellationRow, { label: t.cancellation.estimatedPenalty, value: formatMoney(cancellationPreview.penalty, paymentCurrency) }), _jsx(CancellationRow, { label: t.cancellation.staffAction, value: cancellationPreview.staffActionRequired
|
|
328
|
+
? t.cancellation.staffActionRequired
|
|
329
|
+
: t.cancellation.staffActionNotRequired }), cancellationPreview.warnings.length > 0 ? (_jsx("p", { className: "text-amber-600 text-xs", children: cancellationPreview.warnings.join(", ") })) : null] })) : null] })) : null, _jsx(Section, { title: t.internalNotesSectionTitle, description: t.internalNotesSectionDescription, children: _jsx(Field, { label: t.internalNotesLabel, children: _jsx(Textarea, { rows: 3, value: notes, onChange: (event) => setNotes(event.target.value), placeholder: t.internalNotesPlaceholder }) }) }), _jsx(Section, { title: t.paymentSectionTitle, children: _jsx(Field, { label: t.paymentCurrencyLabel, children: _jsx(CurrencyCombobox, { value: paymentCurrency, onChange: (value) => setPaymentCurrency(value ?? defaultPaymentCurrency) }) }) }), _jsx(Section, { title: t.onReserveSectionTitle, description: t.onReserveSectionDescription, children: _jsx(CheckboxRow, { id: "composer-create-as-draft", checked: createAsDraft, onCheckedChange: setCreateAsDraft, label: t.startInDraftLabel, hint: t.startInDraftHint }) }), _jsx(PrimaryAction, { status: trip?.envelope.status, componentCount: components.length, isBusy: isBusy, pricePending: commitMutation.isPending, reservePending: reserveMutation.isPending, onReserve: () => reserveMutation.mutate() })] }), _jsx("aside", { className: "flex flex-col gap-4 lg:col-span-4", children: _jsx(TripPreviewRail, { trip: trip, pendingCount: pending.length, travelers: travelers, billing: billing, billingPersonId: billing.mode === "existing" ? billing.personId || null : null, voucher: voucher, onVoucherChange: setVoucher, paymentCurrency: paymentCurrency }) })] })] }));
|
|
330
|
+
}
|
|
331
|
+
function CheckboxRow({ id, checked, onCheckedChange, label, hint, }) {
|
|
332
|
+
return (_jsxs("div", { className: "flex items-start gap-2 text-sm", children: [_jsx(Checkbox, { id: id, checked: checked, onCheckedChange: (value) => onCheckedChange(value === true), className: "mt-0.5" }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { htmlFor: id, className: "cursor-pointer text-sm", children: label }), hint ? _jsx("p", { className: "text-muted-foreground text-xs", children: hint }) : null] })] }));
|
|
333
|
+
}
|
|
334
|
+
function CancellationRow({ label, value }) {
|
|
335
|
+
return (_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsx("span", { children: value })] }));
|
|
336
|
+
}
|
|
337
|
+
function formatMoney(amountCents, currency) {
|
|
338
|
+
if (amountCents == null)
|
|
339
|
+
return "-";
|
|
340
|
+
return (amountCents / 100).toLocaleString(undefined, { style: "currency", currency });
|
|
341
|
+
}
|
|
342
|
+
function metadataWithComponentBookingSetup(component, setup) {
|
|
343
|
+
const metadata = { ...(readRecord(component.metadata) ?? {}) };
|
|
344
|
+
const bookingDraft = { ...(readRecord(metadata.bookingDraftV1) ?? {}) };
|
|
345
|
+
const documentGeneration = {
|
|
346
|
+
contractDocument: setup.generateContractDocument,
|
|
347
|
+
invoiceDocument: setup.generateInvoiceDocument,
|
|
348
|
+
};
|
|
349
|
+
metadata.bookingSetup = {
|
|
350
|
+
paymentSchedule: setup.paymentSchedule,
|
|
351
|
+
documentGeneration,
|
|
352
|
+
};
|
|
353
|
+
metadata.bookingDraftV1 = {
|
|
354
|
+
...bookingDraft,
|
|
355
|
+
paymentSchedules: paymentScheduleToRows(setup.paymentSchedule, component.componentCurrency || defaultPaymentCurrency, component.componentTotalAmountCents ?? null),
|
|
356
|
+
documentGeneration,
|
|
357
|
+
};
|
|
358
|
+
return metadata;
|
|
359
|
+
}
|
|
360
|
+
function paymentScheduleToRows(value, scheduleCurrency, totalAmountCents) {
|
|
361
|
+
if (value.mode === "full") {
|
|
362
|
+
const installment = value.installments[0];
|
|
363
|
+
if (!installment?.dueDate || totalAmountCents === null)
|
|
364
|
+
return [];
|
|
365
|
+
return [
|
|
366
|
+
{
|
|
367
|
+
scheduleType: "balance",
|
|
368
|
+
status: installment.alreadyPaid ? "paid" : "due",
|
|
369
|
+
dueDate: installment.dueDate,
|
|
370
|
+
currency: scheduleCurrency,
|
|
371
|
+
amountCents: totalAmountCents,
|
|
372
|
+
notes: paidScheduleNotes(installment.alreadyPaid, installment.paymentDate, installment.paymentMethod, installment.paymentReference),
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
}
|
|
376
|
+
const rows = [];
|
|
377
|
+
for (const installment of value.installments) {
|
|
378
|
+
if (!installment.dueDate || installment.amountCents == null)
|
|
379
|
+
continue;
|
|
380
|
+
rows.push({
|
|
381
|
+
scheduleType: "installment",
|
|
382
|
+
status: installment.alreadyPaid ? "paid" : "due",
|
|
383
|
+
dueDate: installment.dueDate,
|
|
384
|
+
currency: scheduleCurrency,
|
|
385
|
+
amountCents: installment.amountCents,
|
|
386
|
+
notes: paidScheduleNotes(installment.alreadyPaid, installment.paymentDate, installment.paymentMethod, installment.paymentReference),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return rows;
|
|
390
|
+
}
|
|
391
|
+
// Returns a single-line audit note persisted on the booking's payment schedule
|
|
392
|
+
// when the operator marks an installment as already-paid in the composer.
|
|
393
|
+
// Operator-facing free text — kept terse and in English at the data layer so
|
|
394
|
+
// the persisted note stays comparable across deploys / locales.
|
|
395
|
+
function paidScheduleNotes(alreadyPaid, paymentDate, paymentMethod, paymentReference) {
|
|
396
|
+
if (!alreadyPaid)
|
|
397
|
+
return null;
|
|
398
|
+
return [
|
|
399
|
+
// i18n-literal-ok: persisted audit note, see comment above.
|
|
400
|
+
"Marked paid in trip composer",
|
|
401
|
+
paymentDate ? `date: ${paymentDate}` : null,
|
|
402
|
+
paymentMethod ? `method: ${paymentMethod}` : null,
|
|
403
|
+
paymentReference.trim() ? `reference: ${paymentReference.trim()}` : null,
|
|
404
|
+
]
|
|
405
|
+
.filter(Boolean)
|
|
406
|
+
.join("; ");
|
|
407
|
+
}
|
|
408
|
+
function pendingToAddInput(pending, ctx, messages) {
|
|
409
|
+
const billingPayload = serializeBilling(ctx.billing, ctx.payerName, ctx.payerEmail);
|
|
410
|
+
const travelersPayload = serializeTravelersForBookingDraft(ctx.travelers, messages);
|
|
411
|
+
const paxAdult = countAdults(ctx.travelers) || 1;
|
|
412
|
+
if (pending.kind === "product" || pending.kind === "stay") {
|
|
413
|
+
if (!pending.catalogEntityId || !pending.catalogSourceKind)
|
|
414
|
+
return null;
|
|
415
|
+
const vertical = pending.kind === "stay" ? "accommodations" : "products";
|
|
416
|
+
const draft = pending.bookingDraft;
|
|
417
|
+
const configure = {
|
|
418
|
+
...(draft?.configure ?? {}),
|
|
419
|
+
pax: {
|
|
420
|
+
...(draft?.configure.pax ?? {}),
|
|
421
|
+
adult: paxAdult,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
if (pending.startsAt) {
|
|
425
|
+
configure.departureDate = pending.startsAt.slice(0, 10);
|
|
426
|
+
}
|
|
427
|
+
if (pending.startsAt && pending.endsAt) {
|
|
428
|
+
configure.dateRange = {
|
|
429
|
+
checkIn: pending.startsAt.slice(0, 10),
|
|
430
|
+
checkOut: pending.endsAt.slice(0, 10),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
kind: "catalog_booking",
|
|
435
|
+
catalogRef: {
|
|
436
|
+
entityModule: vertical,
|
|
437
|
+
entityId: pending.catalogEntityId,
|
|
438
|
+
sourceKind: pending.catalogSourceKind,
|
|
439
|
+
...(pending.catalogSourceConnectionId
|
|
440
|
+
? { sourceConnectionId: pending.catalogSourceConnectionId }
|
|
441
|
+
: {}),
|
|
442
|
+
...(pending.catalogSourceRef ? { sourceRef: pending.catalogSourceRef } : {}),
|
|
443
|
+
},
|
|
444
|
+
metadata: {
|
|
445
|
+
scheduledStartsAt: pending.startsAt || null,
|
|
446
|
+
scheduledEndsAt: pending.endsAt || null,
|
|
447
|
+
catalogItem: {
|
|
448
|
+
vertical,
|
|
449
|
+
name: pending.catalogEntityName,
|
|
450
|
+
thumbnailUrl: pending.catalogThumbnailUrl,
|
|
451
|
+
sourceKind: pending.catalogSourceKind,
|
|
452
|
+
sourceConnectionId: pending.catalogSourceConnectionId,
|
|
453
|
+
sourceRef: pending.catalogSourceRef,
|
|
454
|
+
},
|
|
455
|
+
bookingDraftV1: {
|
|
456
|
+
...(draft ?? {}),
|
|
457
|
+
entity: draft?.entity ?? {
|
|
458
|
+
module: vertical,
|
|
459
|
+
id: pending.catalogEntityId,
|
|
460
|
+
sourceKind: pending.catalogSourceKind,
|
|
461
|
+
...(pending.catalogSourceConnectionId
|
|
462
|
+
? { sourceConnectionId: pending.catalogSourceConnectionId }
|
|
463
|
+
: {}),
|
|
464
|
+
...(pending.catalogSourceRef ? { sourceRef: pending.catalogSourceRef } : {}),
|
|
465
|
+
},
|
|
466
|
+
configure,
|
|
467
|
+
billing: billingPayload,
|
|
468
|
+
travelers: travelersPayload,
|
|
469
|
+
payment: draft?.payment ?? { intent: "hold" },
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (pending.kind === "flight") {
|
|
475
|
+
const pricing = flightPricingFromPending(pending);
|
|
476
|
+
const firstItinerary = pending.selectedOffer?.itineraries[0];
|
|
477
|
+
const lastItinerary = pending.selectedOffer?.itineraries[pending.selectedOffer.itineraries.length - 1];
|
|
478
|
+
const firstSegment = firstItinerary?.segments[0];
|
|
479
|
+
const lastSegment = lastItinerary?.segments[lastItinerary.segments.length - 1];
|
|
480
|
+
return {
|
|
481
|
+
kind: "flight_placeholder",
|
|
482
|
+
description: undefined,
|
|
483
|
+
estimatedPricing: {
|
|
484
|
+
currency: pricing.currency,
|
|
485
|
+
subtotalAmountCents: pricing.subtotalAmountCents,
|
|
486
|
+
taxAmountCents: pricing.taxAmountCents,
|
|
487
|
+
totalAmountCents: pricing.totalAmountCents,
|
|
488
|
+
},
|
|
489
|
+
metadata: {
|
|
490
|
+
scheduledStartsAt: firstSegment?.departure.at ?? pending.departDate ?? null,
|
|
491
|
+
scheduledEndsAt: lastSegment?.arrival.at ?? pending.returnDate ?? null,
|
|
492
|
+
flightDraft: {
|
|
493
|
+
origin: pending.origin,
|
|
494
|
+
destination: pending.destination,
|
|
495
|
+
departDate: pending.departDate,
|
|
496
|
+
returnDate: pending.returnDate || null,
|
|
497
|
+
tripType: pending.tripType,
|
|
498
|
+
cabin: pending.cabin,
|
|
499
|
+
offerId: pending.selectedOffer?.offerId ?? null,
|
|
500
|
+
source: pending.selectedOffer?.source ?? null,
|
|
501
|
+
selectedOffer: pending.selectedOffer,
|
|
502
|
+
ancillaries: {
|
|
503
|
+
fareBundle: pending.fareBundlePicks,
|
|
504
|
+
baggage: pending.baggagePicks,
|
|
505
|
+
assistance: pending.assistancePicks,
|
|
506
|
+
extras: pending.extrasPicks,
|
|
507
|
+
},
|
|
508
|
+
pricing,
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (pending.kind === "cruise") {
|
|
514
|
+
const amountCents = parseAmountCents(pending.estimatedAmount);
|
|
515
|
+
return {
|
|
516
|
+
kind: "manual_placeholder",
|
|
517
|
+
description: pending.description || undefined,
|
|
518
|
+
estimatedPricing: pricingFromAmount(amountCents, ctx.paymentCurrency),
|
|
519
|
+
metadata: {
|
|
520
|
+
scheduledStartsAt: pending.embarkationDate || null,
|
|
521
|
+
scheduledEndsAt: null,
|
|
522
|
+
cruiseDraft: {
|
|
523
|
+
cabin: pending.cabin || null,
|
|
524
|
+
embarkationDate: pending.embarkationDate || null,
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
const totals = computePlaceholderTotals(pending.subtotalCents, pending.taxRatePct);
|
|
530
|
+
return {
|
|
531
|
+
kind: "manual_placeholder",
|
|
532
|
+
description: pending.description || undefined,
|
|
533
|
+
estimatedPricing: {
|
|
534
|
+
currency: pending.currency,
|
|
535
|
+
subtotalAmountCents: totals.subtotal,
|
|
536
|
+
taxAmountCents: totals.tax,
|
|
537
|
+
totalAmountCents: totals.total,
|
|
538
|
+
},
|
|
539
|
+
metadata: {
|
|
540
|
+
scheduledStartsAt: pending.startsAt || null,
|
|
541
|
+
scheduledEndsAt: pending.endsAt || null,
|
|
542
|
+
manualService: {
|
|
543
|
+
name: pending.name,
|
|
544
|
+
},
|
|
545
|
+
taxRatePct: pending.taxRatePct || null,
|
|
546
|
+
template: pending.kind,
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function pricingFromAmount(amountCents, pricingCurrency) {
|
|
551
|
+
return {
|
|
552
|
+
currency: pricingCurrency,
|
|
553
|
+
subtotalAmountCents: amountCents,
|
|
554
|
+
taxAmountCents: 0,
|
|
555
|
+
totalAmountCents: amountCents,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function parseAmountCents(raw) {
|
|
559
|
+
const parsed = Number.parseFloat(raw || "0");
|
|
560
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 100) : 0;
|
|
561
|
+
}
|
|
562
|
+
function countAdults(travelers) {
|
|
563
|
+
return travelers.filter((t) => t.role === "lead" || t.role === "adult").length;
|
|
564
|
+
}
|
|
565
|
+
function serializeBilling(billing, payerNameFallback, payerEmailFallback) {
|
|
566
|
+
if (billing.mode === "new") {
|
|
567
|
+
return {
|
|
568
|
+
buyerType: billing.billTo === "organization" ? "B2B" : "B2C",
|
|
569
|
+
contact: {
|
|
570
|
+
firstName: billing.newPerson.firstName.trim(),
|
|
571
|
+
lastName: billing.newPerson.lastName.trim(),
|
|
572
|
+
email: billing.newPerson.email.trim(),
|
|
573
|
+
phone: billing.newPerson.phone || undefined,
|
|
574
|
+
},
|
|
575
|
+
address: {},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
// For an existing person we still need a contact block — the booking engine
|
|
579
|
+
// validates `billing.contact` even when an id is present. Names/emails come
|
|
580
|
+
// from the resolved person (payerName / payerEmail).
|
|
581
|
+
const [firstName, ...rest] = (payerNameFallback ?? "").trim().split(/\s+/);
|
|
582
|
+
return {
|
|
583
|
+
buyerType: billing.billTo === "organization" ? "B2B" : "B2C",
|
|
584
|
+
...(billing.personId ? { personId: billing.personId } : {}),
|
|
585
|
+
...(billing.organizationId ? { organizationId: billing.organizationId } : {}),
|
|
586
|
+
contact: {
|
|
587
|
+
firstName: firstName || "",
|
|
588
|
+
lastName: rest.join(" ") || "",
|
|
589
|
+
email: payerEmailFallback || "",
|
|
590
|
+
},
|
|
591
|
+
address: {},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function assertTripCreationRequirements(ctx, messages) {
|
|
595
|
+
const errors = [];
|
|
596
|
+
const { errors: errorMessages } = messages;
|
|
597
|
+
if (ctx.billing.mode === "new") {
|
|
598
|
+
if (!ctx.billing.newPerson.firstName.trim() || !ctx.billing.newPerson.lastName.trim()) {
|
|
599
|
+
errors.push(errorMessages.requirementBillingName);
|
|
600
|
+
}
|
|
601
|
+
if (!isRealTripEmail(ctx.billing.newPerson.email)) {
|
|
602
|
+
errors.push(errorMessages.requirementBillingEmail);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
const hasBillingRecord = ctx.billing.billTo === "organization"
|
|
607
|
+
? Boolean(ctx.billing.organizationId)
|
|
608
|
+
: Boolean(ctx.billing.personId);
|
|
609
|
+
if (!hasBillingRecord)
|
|
610
|
+
errors.push(errorMessages.requirementBillingPersonOrOrg);
|
|
611
|
+
if (!ctx.payerName.trim())
|
|
612
|
+
errors.push(errorMessages.requirementBillingName);
|
|
613
|
+
if (!isRealTripEmail(ctx.payerEmail))
|
|
614
|
+
errors.push(errorMessages.requirementBillingEmail);
|
|
615
|
+
}
|
|
616
|
+
if (ctx.travelers.length === 0) {
|
|
617
|
+
errors.push(errorMessages.requirementAtLeastOneTraveler);
|
|
618
|
+
}
|
|
619
|
+
ctx.travelers.forEach((traveler, index) => {
|
|
620
|
+
if (!traveler.personId && (!traveler.firstName.trim() || !traveler.lastName.trim())) {
|
|
621
|
+
errors.push(formatMessage(errorMessages.requirementTravelerName, { position: index + 1 }));
|
|
622
|
+
}
|
|
623
|
+
if (traveler.email && !isRealTripEmail(traveler.email)) {
|
|
624
|
+
errors.push(formatMessage(errorMessages.requirementTravelerEmail, { position: index + 1 }));
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
if (errors.length > 0) {
|
|
628
|
+
throw new Error(formatMessage(errorMessages.completeRequirements, { fields: errors.join(", ") }));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function isRealTripEmail(value) {
|
|
632
|
+
const normalized = value?.trim().toLowerCase() ?? "";
|
|
633
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized))
|
|
634
|
+
return false;
|
|
635
|
+
return !["noreply@example.com", "tbd@example.com", "traveler@example.com"].includes(normalized);
|
|
636
|
+
}
|
|
637
|
+
function hydrateBilling(travelerParty) {
|
|
638
|
+
const billing = readRecord(travelerParty.billing);
|
|
639
|
+
if (!billing)
|
|
640
|
+
return emptyPersonPickerValue;
|
|
641
|
+
const contact = readRecord(billing.contact);
|
|
642
|
+
const personId = stringFromRecord(billing, "personId") ?? "";
|
|
643
|
+
const organizationId = stringFromRecord(billing, "organizationId") ?? null;
|
|
644
|
+
const billTo = organizationId || stringFromRecord(billing, "buyerType") === "B2B" ? "organization" : "person";
|
|
645
|
+
if (personId || organizationId) {
|
|
646
|
+
return {
|
|
647
|
+
billTo,
|
|
648
|
+
mode: "existing",
|
|
649
|
+
personId,
|
|
650
|
+
organizationId,
|
|
651
|
+
newPerson: emptyPersonPickerValue.newPerson,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
billTo,
|
|
656
|
+
mode: "new",
|
|
657
|
+
personId: "",
|
|
658
|
+
organizationId,
|
|
659
|
+
newPerson: {
|
|
660
|
+
firstName: stringFromRecord(contact, "firstName") ?? "",
|
|
661
|
+
lastName: stringFromRecord(contact, "lastName") ?? "",
|
|
662
|
+
email: stringFromRecord(contact, "email") ?? "",
|
|
663
|
+
phone: stringFromRecord(contact, "phone") ?? "",
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function hydrateTravelers(travelerParty) {
|
|
668
|
+
const travelers = travelerParty.travelers;
|
|
669
|
+
if (!Array.isArray(travelers))
|
|
670
|
+
return [];
|
|
671
|
+
return travelers.filter(readRecord).map((traveler, index) => ({
|
|
672
|
+
localId: stringFromRecord(traveler, "localId") ?? `tt_existing_${index}`,
|
|
673
|
+
personId: stringFromRecord(traveler, "personId") ?? null,
|
|
674
|
+
firstName: stringFromRecord(traveler, "firstName") ?? "",
|
|
675
|
+
lastName: stringFromRecord(traveler, "lastName") ?? "",
|
|
676
|
+
email: stringFromRecord(traveler, "email") ?? "",
|
|
677
|
+
dateOfBirth: stringFromRecord(traveler, "dateOfBirth") ?? null,
|
|
678
|
+
role: tripTravelerRoleFromStored(stringFromRecord(traveler, "role"), index),
|
|
679
|
+
}));
|
|
680
|
+
}
|
|
681
|
+
function hydrateVoucher(travelerParty) {
|
|
682
|
+
const voucher = readRecord(travelerParty.voucher);
|
|
683
|
+
if (!voucher)
|
|
684
|
+
return emptyVoucherPickerValue;
|
|
685
|
+
const id = stringFromRecord(voucher, "id");
|
|
686
|
+
const code = stringFromRecord(voucher, "code");
|
|
687
|
+
const currencyCode = stringFromRecord(voucher, "currency");
|
|
688
|
+
const remainingAmountCents = numberFromRecord(voucher, "remainingAmountCents");
|
|
689
|
+
if (!id || !code || !currencyCode || remainingAmountCents == null)
|
|
690
|
+
return emptyVoucherPickerValue;
|
|
691
|
+
return {
|
|
692
|
+
code,
|
|
693
|
+
picked: {
|
|
694
|
+
id,
|
|
695
|
+
code,
|
|
696
|
+
label: null,
|
|
697
|
+
currency: currencyCode,
|
|
698
|
+
remainingAmountCents,
|
|
699
|
+
expiresAt: null,
|
|
700
|
+
},
|
|
701
|
+
error: null,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function tripTravelerRoleFromStored(value, index) {
|
|
705
|
+
if (value === "lead" || value === "adult" || value === "child" || value === "infant") {
|
|
706
|
+
return value;
|
|
707
|
+
}
|
|
708
|
+
return index === 0 ? "lead" : "adult";
|
|
709
|
+
}
|
|
710
|
+
// Map our roster shape onto the catalog booking engine's `travelerEntryV1`:
|
|
711
|
+
// drop empty/null fields it can't validate, translate `role` (lead/adult/...)
|
|
712
|
+
// into `band` (adult/child/infant) + `isPrimary`.
|
|
713
|
+
function serializeTravelersForBookingDraft(travelers, messages) {
|
|
714
|
+
return travelers.map((traveler) => {
|
|
715
|
+
const band = traveler.role === "child" ? "child" : traveler.role === "infant" ? "infant" : "adult";
|
|
716
|
+
const firstName = traveler.firstName.trim();
|
|
717
|
+
const lastName = traveler.lastName.trim();
|
|
718
|
+
const email = traveler.email.trim();
|
|
719
|
+
const dateOfBirth = traveler.dateOfBirth?.trim() || "";
|
|
720
|
+
const entry = {
|
|
721
|
+
firstName: firstName || messages.travelerFallbackName,
|
|
722
|
+
lastName: lastName || messages.travelerFallbackLastName,
|
|
723
|
+
band,
|
|
724
|
+
};
|
|
725
|
+
if (email)
|
|
726
|
+
entry.email = email;
|
|
727
|
+
if (dateOfBirth)
|
|
728
|
+
entry.dateOfBirth = dateOfBirth;
|
|
729
|
+
if (traveler.role === "lead")
|
|
730
|
+
entry.isPrimary = true;
|
|
731
|
+
return entry;
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function failuresToString(failures, messages) {
|
|
735
|
+
if (!failures || failures.length === 0)
|
|
736
|
+
return null;
|
|
737
|
+
if (failures.some((failure) => failure.code === "price_changed")) {
|
|
738
|
+
return messages.failureMessages.priceChanged;
|
|
739
|
+
}
|
|
740
|
+
if (failures.some((failure) => failure.code === "expired")) {
|
|
741
|
+
return messages.failureMessages.expired;
|
|
742
|
+
}
|
|
743
|
+
if (failures.some((failure) => failure.code === "unavailable")) {
|
|
744
|
+
return messages.failureMessages.unavailable;
|
|
745
|
+
}
|
|
746
|
+
return failures.map((failure) => failure.reason).join(", ");
|
|
747
|
+
}
|
|
748
|
+
function apiError(error, messages) {
|
|
749
|
+
const candidate = error;
|
|
750
|
+
if (typeof candidate.message === "string")
|
|
751
|
+
return candidate.message;
|
|
752
|
+
return error instanceof Error ? error.message : messages.errors.requestFailed;
|
|
753
|
+
}
|
|
754
|
+
function derivePayerName(billing, person, messages) {
|
|
755
|
+
if (billing.mode === "new") {
|
|
756
|
+
const name = [billing.newPerson.firstName, billing.newPerson.lastName]
|
|
757
|
+
.filter((part) => part.trim().length > 0)
|
|
758
|
+
.join(" ")
|
|
759
|
+
.trim();
|
|
760
|
+
return name || billing.newPerson.email.trim() || messages.travelerFallbackName;
|
|
761
|
+
}
|
|
762
|
+
if (person) {
|
|
763
|
+
const name = [person.firstName, person.lastName]
|
|
764
|
+
.filter((part) => (part ?? "").trim().length > 0)
|
|
765
|
+
.join(" ")
|
|
766
|
+
.trim();
|
|
767
|
+
return name || (person.email ?? "") || messages.travelerFallbackName;
|
|
768
|
+
}
|
|
769
|
+
return messages.travelerFallbackName;
|
|
770
|
+
}
|
|
771
|
+
function derivePayerEmail(billing, person) {
|
|
772
|
+
if (billing.mode === "new") {
|
|
773
|
+
return billing.newPerson.email.trim();
|
|
774
|
+
}
|
|
775
|
+
return person?.email ?? "";
|
|
776
|
+
}
|
|
777
|
+
function readRecord(value) {
|
|
778
|
+
return isRecord(value) ? value : null;
|
|
779
|
+
}
|
|
780
|
+
function isRecord(value) {
|
|
781
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
782
|
+
}
|
|
783
|
+
function stringFromRecord(record, key) {
|
|
784
|
+
const value = record?.[key];
|
|
785
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
786
|
+
}
|
|
787
|
+
function numberFromRecord(record, key) {
|
|
788
|
+
const value = record?.[key];
|
|
789
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
790
|
+
}
|
|
791
|
+
function booleanFromRecord(record, key) {
|
|
792
|
+
return record[key] === true;
|
|
793
|
+
}
|