@voyantjs/travel-composer-react 0.105.7 → 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,1372 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { useOperatorAdminMessages as useAdminMessages } from "@voyantjs/admin";
|
|
5
|
+
import { emptyPaymentScheduleValue } from "@voyantjs/bookings-react/components/payment-schedule-section";
|
|
6
|
+
import { deriveTravelerRoleFromDob } from "@voyantjs/bookings-react/components/travelers-section";
|
|
7
|
+
import { emptyDraft, setAccommodation, setAddons, } from "@voyantjs/bookings-react/journey";
|
|
8
|
+
import { PaymentScheduleSection, VoucherPickerSection, } from "@voyantjs/bookings-react/ui";
|
|
9
|
+
import { useCatalogSearch } from "@voyantjs/catalog-react";
|
|
10
|
+
import { useBookingQuote } from "@voyantjs/catalog-react/booking-engine";
|
|
11
|
+
import { useOrganization, usePerson, usePersonRelationships } from "@voyantjs/crm-react";
|
|
12
|
+
import { PersonCombobox, PersonForm } from "@voyantjs/crm-react/ui";
|
|
13
|
+
import { useFlightAncillaries, useFlightSearch } from "@voyantjs/flights-react";
|
|
14
|
+
import { AirportCombobox, FlightBaggageStep, FlightFareUpsellStep, FlightOfferRow, FlightServicesStep, } from "@voyantjs/flights-react/ui";
|
|
15
|
+
import { formatMessage } from "@voyantjs/i18n";
|
|
16
|
+
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from "@voyantjs/ui/components";
|
|
17
|
+
import { Alert, AlertDescription, AlertTitle } from "@voyantjs/ui/components/alert";
|
|
18
|
+
import { AsyncCombobox } from "@voyantjs/ui/components/async-combobox";
|
|
19
|
+
import { Badge } from "@voyantjs/ui/components/badge";
|
|
20
|
+
import { Button } from "@voyantjs/ui/components/button";
|
|
21
|
+
import { Checkbox } from "@voyantjs/ui/components/checkbox";
|
|
22
|
+
import { CurrencyCombobox } from "@voyantjs/ui/components/currency-combobox";
|
|
23
|
+
import { CurrencyInput } from "@voyantjs/ui/components/currency-input";
|
|
24
|
+
import { DatePicker } from "@voyantjs/ui/components/date-picker";
|
|
25
|
+
import { DateTimeField } from "@voyantjs/ui/components/date-time-field";
|
|
26
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@voyantjs/ui/components/dropdown-menu";
|
|
27
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@voyantjs/ui/components/empty";
|
|
28
|
+
import { Input } from "@voyantjs/ui/components/input";
|
|
29
|
+
import { Label } from "@voyantjs/ui/components/label";
|
|
30
|
+
import { Textarea } from "@voyantjs/ui/components/textarea";
|
|
31
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@voyantjs/ui/components/tooltip";
|
|
32
|
+
import { AlertTriangle, BedDouble, CalendarClock, Check, CircleAlert, ExternalLink, Info, Landmark, Loader2, Minus, Pencil, Plane, Plus, Route as RouteIcon, Sailboat, Trash2, UserPlus, Wrench, X, } from "lucide-react";
|
|
33
|
+
import * as React from "react";
|
|
34
|
+
import { useVoyantTravelComposerContext } from "../provider.js";
|
|
35
|
+
function verticalsFor(messages) {
|
|
36
|
+
const v = messages.verticals;
|
|
37
|
+
return [
|
|
38
|
+
{ kind: "product", label: v.productLabel, description: v.productDescription },
|
|
39
|
+
{ kind: "stay", label: v.stayLabel, description: v.stayDescription },
|
|
40
|
+
{ kind: "flight", label: v.flightLabel, description: v.flightDescription },
|
|
41
|
+
{ kind: "cruise", label: v.cruiseLabel, description: v.cruiseDescription },
|
|
42
|
+
{ kind: "manual", label: v.manualLabel, description: v.manualDescription },
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
function verticalForKind(kind) {
|
|
46
|
+
return kind === "stay" ? "accommodations" : "products";
|
|
47
|
+
}
|
|
48
|
+
function genLocalId() {
|
|
49
|
+
return `pc_${Math.random().toString(36).slice(2, 10)}`;
|
|
50
|
+
}
|
|
51
|
+
function catalogHitLabel(hit) {
|
|
52
|
+
return (catalogHitStringField(hit, "name") ??
|
|
53
|
+
catalogHitStringField(hit, "title") ??
|
|
54
|
+
catalogHitStringField(hit, "hotel.name") ??
|
|
55
|
+
hit.id);
|
|
56
|
+
}
|
|
57
|
+
function catalogHitSourceKind(hit) {
|
|
58
|
+
return catalogHitStringField(hit, "source.kind");
|
|
59
|
+
}
|
|
60
|
+
function catalogHitThumbnailUrl(hit) {
|
|
61
|
+
return (catalogHitStringField(hit, "thumbnailUrl") ??
|
|
62
|
+
catalogHitStringField(hit, "hero_image_url") ??
|
|
63
|
+
catalogHitStringField(hit, "imageUrl"));
|
|
64
|
+
}
|
|
65
|
+
function catalogHitSourceConnectionId(hit) {
|
|
66
|
+
return (catalogHitStringField(hit, "source.connectionId") ??
|
|
67
|
+
catalogHitStringField(hit, "source_connection_id"));
|
|
68
|
+
}
|
|
69
|
+
function catalogHitSourceRef(hit) {
|
|
70
|
+
return catalogHitStringField(hit, "source.ref") ?? catalogHitStringField(hit, "source_ref");
|
|
71
|
+
}
|
|
72
|
+
function catalogHitStringField(hit, field) {
|
|
73
|
+
const value = hit.document.fields[field];
|
|
74
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
75
|
+
}
|
|
76
|
+
export function newPendingComponent(kind) {
|
|
77
|
+
const localId = genLocalId();
|
|
78
|
+
switch (kind) {
|
|
79
|
+
case "product":
|
|
80
|
+
case "stay":
|
|
81
|
+
return {
|
|
82
|
+
kind,
|
|
83
|
+
localId,
|
|
84
|
+
catalogEntityId: null,
|
|
85
|
+
catalogEntityName: null,
|
|
86
|
+
catalogSourceKind: null,
|
|
87
|
+
catalogSourceConnectionId: null,
|
|
88
|
+
catalogSourceRef: null,
|
|
89
|
+
catalogThumbnailUrl: null,
|
|
90
|
+
bookingDraft: null,
|
|
91
|
+
startsAt: "",
|
|
92
|
+
endsAt: "",
|
|
93
|
+
commitError: null,
|
|
94
|
+
};
|
|
95
|
+
case "flight":
|
|
96
|
+
return {
|
|
97
|
+
kind,
|
|
98
|
+
localId,
|
|
99
|
+
tripType: "one_way",
|
|
100
|
+
origin: null,
|
|
101
|
+
destination: null,
|
|
102
|
+
departDate: "",
|
|
103
|
+
returnDate: "",
|
|
104
|
+
cabin: "economy",
|
|
105
|
+
selectedOffer: null,
|
|
106
|
+
ancillaryCatalog: null,
|
|
107
|
+
fareBundlePicks: [],
|
|
108
|
+
baggagePicks: [],
|
|
109
|
+
assistancePicks: [],
|
|
110
|
+
extrasPicks: [],
|
|
111
|
+
sameFareForAllPassengers: true,
|
|
112
|
+
sameBaggageBothDirections: true,
|
|
113
|
+
commitError: null,
|
|
114
|
+
};
|
|
115
|
+
case "cruise":
|
|
116
|
+
return {
|
|
117
|
+
kind,
|
|
118
|
+
localId,
|
|
119
|
+
description: "",
|
|
120
|
+
cabin: "",
|
|
121
|
+
embarkationDate: "",
|
|
122
|
+
estimatedAmount: "",
|
|
123
|
+
commitError: null,
|
|
124
|
+
};
|
|
125
|
+
default:
|
|
126
|
+
return {
|
|
127
|
+
kind,
|
|
128
|
+
localId,
|
|
129
|
+
name: "",
|
|
130
|
+
description: "",
|
|
131
|
+
currency: "EUR",
|
|
132
|
+
subtotalCents: null,
|
|
133
|
+
taxRatePct: "",
|
|
134
|
+
startsAt: "",
|
|
135
|
+
endsAt: "",
|
|
136
|
+
commitError: null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function computePlaceholderTotals(subtotalCents, taxRatePct) {
|
|
141
|
+
const subtotal = subtotalCents ?? 0;
|
|
142
|
+
const rate = Number.parseFloat(taxRatePct || "0") / 100;
|
|
143
|
+
const validRate = Number.isFinite(rate) && rate >= 0 ? rate : 0;
|
|
144
|
+
const tax = Math.round(subtotal * validRate);
|
|
145
|
+
return { subtotal, tax, total: subtotal + tax };
|
|
146
|
+
}
|
|
147
|
+
export function Section({ title, description, action, children, className, }) {
|
|
148
|
+
return (_jsxs("section", { className: `flex flex-col gap-4 rounded-md border bg-card p-5 ${className ?? ""}`, children: [title || description || action ? (_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [title ? _jsx("h2", { className: "font-medium text-base", children: title }) : null, description ? _jsx("p", { className: "text-muted-foreground text-sm", children: description }) : null] }), action] })) : null, children] }));
|
|
149
|
+
}
|
|
150
|
+
export function Field({ label, className, children, }) {
|
|
151
|
+
return (_jsxs("div", { className: className ? `flex flex-col gap-1.5 ${className}` : "flex flex-col gap-1.5", children: [_jsx(Label, { className: "text-muted-foreground text-xs", children: label }), children] }));
|
|
152
|
+
}
|
|
153
|
+
export function StatusAlert({ title, message, tone, }) {
|
|
154
|
+
return (_jsxs(Alert, { variant: tone === "error" ? "destructive" : "default", children: [tone === "error" ? _jsx(CircleAlert, { className: "size-4" }) : _jsx(Check, { className: "size-4" }), _jsx(AlertTitle, { children: title }), _jsx(AlertDescription, { children: message })] }));
|
|
155
|
+
}
|
|
156
|
+
export function AddComponentMenu({ onAdd, disabled, }) {
|
|
157
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
158
|
+
const verticals = verticalsFor(t);
|
|
159
|
+
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsxs(Button, { variant: "outline", disabled: disabled, className: "w-full", children: [_jsx(Plus, { className: "size-4" }), t.addComponentMenu] }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-72", children: verticals.map((vertical) => {
|
|
160
|
+
const Icon = verticalIconFor(vertical.kind);
|
|
161
|
+
return (_jsxs(DropdownMenuItem, { onClick: () => onAdd(vertical.kind), className: "flex items-start gap-3 py-2", children: [_jsx("span", { className: "flex size-7 shrink-0 items-center justify-center rounded-md bg-muted", children: _jsx(Icon, { className: "size-4" }) }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "font-medium text-sm", children: vertical.label }), _jsx("span", { className: "text-muted-foreground text-xs", children: vertical.description })] })] }, vertical.kind));
|
|
162
|
+
}) })] }));
|
|
163
|
+
}
|
|
164
|
+
export function PendingComponentCard({ pending, onChange, onRemove, onCommit, committing, travelers, }) {
|
|
165
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
166
|
+
const Icon = verticalIconFor(pending.kind);
|
|
167
|
+
const label = verticalLabelFor(pending.kind, t);
|
|
168
|
+
const valid = pendingComponentIsValid(pending);
|
|
169
|
+
return (_jsxs("div", { className: "flex flex-col gap-4 rounded-md border bg-card p-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-md bg-muted", children: _jsx(Icon, { className: "size-4" }) }), _jsxs("div", { children: [_jsx("h3", { className: "font-medium text-base", children: label }), _jsx("p", { className: "text-muted-foreground text-xs", children: t.configureHint })] })] }), _jsx(Button, { variant: "ghost", size: "sm", onClick: onRemove, "aria-label": t.removeComponent, children: _jsx(Trash2, { className: "size-4" }) })] }), _jsx(PendingBody, { pending: pending, onChange: onChange, travelers: travelers }), pending.commitError ? (_jsx("p", { className: "rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm", children: pending.commitError })) : null, _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { onClick: onCommit, disabled: !valid || committing, children: [committing ? _jsx(Loader2, { className: "size-4 animate-spin" }) : _jsx(Plus, { className: "size-4" }), "Add to trip"] }) })] }));
|
|
170
|
+
}
|
|
171
|
+
function PendingBody({ pending, onChange, travelers, }) {
|
|
172
|
+
const passengerCounts = passengerCountsFromTripTravelers(travelers);
|
|
173
|
+
if (pending.kind === "product" || pending.kind === "stay") {
|
|
174
|
+
return (_jsx(CatalogConfigurator, { pending: pending, onChange: onChange, paxAdult: passengerCounts.adults }));
|
|
175
|
+
}
|
|
176
|
+
if (pending.kind === "flight") {
|
|
177
|
+
return _jsx(FlightConfigurator, { pending: pending, travelers: travelers, onChange: onChange });
|
|
178
|
+
}
|
|
179
|
+
if (pending.kind === "cruise") {
|
|
180
|
+
return _jsx(CruiseConfigurator, { pending: pending, onChange: onChange });
|
|
181
|
+
}
|
|
182
|
+
return _jsx(PlaceholderConfigurator, { pending: pending, onChange: onChange });
|
|
183
|
+
}
|
|
184
|
+
function CatalogConfigurator({ pending, onChange, paxAdult, }) {
|
|
185
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
186
|
+
const vertical = verticalForKind(pending.kind);
|
|
187
|
+
const [catalogSearch, setCatalogSearch] = React.useState("");
|
|
188
|
+
const [selectedCatalogHit, setSelectedCatalogHit] = React.useState(null);
|
|
189
|
+
const catalogQuery = useCatalogSearch({
|
|
190
|
+
vertical,
|
|
191
|
+
query: catalogSearch,
|
|
192
|
+
mode: "keyword",
|
|
193
|
+
pagination: { limit: 20 },
|
|
194
|
+
enabled: true,
|
|
195
|
+
});
|
|
196
|
+
const catalogHits = React.useMemo(() => {
|
|
197
|
+
return catalogQuery.data?.hits ?? [];
|
|
198
|
+
}, [catalogQuery.data?.hits]);
|
|
199
|
+
const selectedCatalog = catalogHits.find((hit) => hit.id === pending.catalogEntityId) ?? selectedCatalogHit;
|
|
200
|
+
const departureQuery = useProductDepartures(pending.kind === "product" ? pending.catalogEntityId : null);
|
|
201
|
+
const selectedDeparture = React.useMemo(() => departureQuery.data?.rows.find((slot) => slot.id === pending.bookingDraft?.configure.departureSlotId) ?? null, [departureQuery.data?.rows, pending.bookingDraft?.configure.departureSlotId]);
|
|
202
|
+
const quoteDraft = pending.bookingDraft && (pending.bookingDraft.configure.pax?.adult ?? 0) !== paxAdult
|
|
203
|
+
? updateDraftPax(pending.bookingDraft, paxAdult)
|
|
204
|
+
: pending.bookingDraft;
|
|
205
|
+
const quote = useBookingQuote({
|
|
206
|
+
surface: "admin",
|
|
207
|
+
draft: quoteDraft,
|
|
208
|
+
scope: { locale: "en-GB", audience: "staff", market: "default" },
|
|
209
|
+
enabled: Boolean(pending.bookingDraft),
|
|
210
|
+
});
|
|
211
|
+
const shape = quote.data?.shape ?? null;
|
|
212
|
+
React.useEffect(() => {
|
|
213
|
+
if (!pending.bookingDraft)
|
|
214
|
+
return;
|
|
215
|
+
const currentAdult = pending.bookingDraft.configure.pax?.adult ?? 0;
|
|
216
|
+
if (currentAdult === paxAdult)
|
|
217
|
+
return;
|
|
218
|
+
onChange({ ...pending, bookingDraft: updateDraftPax(pending.bookingDraft, paxAdult) });
|
|
219
|
+
}, [onChange, paxAdult, pending]);
|
|
220
|
+
const fieldLabel = pending.kind === "stay" ? t.catalogSearch.hotelLabel : t.catalogSearch.productLabel;
|
|
221
|
+
const placeholder = pending.kind === "stay"
|
|
222
|
+
? t.catalogSearch.hotelSearchPlaceholder
|
|
223
|
+
: t.catalogSearch.productSearchPlaceholder;
|
|
224
|
+
const emptyText = pending.kind === "stay" ? t.catalogSearch.hotelEmpty : t.catalogSearch.productEmpty;
|
|
225
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Field, { label: fieldLabel, children: _jsx(AsyncCombobox, { value: pending.catalogEntityId, onChange: (value) => {
|
|
226
|
+
const hit = value ? catalogHits.find((item) => item.id === value) : null;
|
|
227
|
+
setSelectedCatalogHit(hit ?? null);
|
|
228
|
+
const nextEntityId = value;
|
|
229
|
+
const nextSourceKind = hit ? catalogHitSourceKind(hit) : null;
|
|
230
|
+
const nextSourceConnectionId = hit ? catalogHitSourceConnectionId(hit) : null;
|
|
231
|
+
const nextSourceRef = hit ? catalogHitSourceRef(hit) : null;
|
|
232
|
+
onChange({
|
|
233
|
+
...pending,
|
|
234
|
+
catalogEntityId: nextEntityId,
|
|
235
|
+
catalogEntityName: hit ? catalogHitLabel(hit) : null,
|
|
236
|
+
catalogSourceKind: nextSourceKind,
|
|
237
|
+
catalogSourceConnectionId: nextSourceConnectionId,
|
|
238
|
+
catalogSourceRef: nextSourceRef,
|
|
239
|
+
catalogThumbnailUrl: hit ? catalogHitThumbnailUrl(hit) : null,
|
|
240
|
+
bookingDraft: nextEntityId && nextSourceKind
|
|
241
|
+
? createCatalogBookingDraft({
|
|
242
|
+
vertical,
|
|
243
|
+
entityId: nextEntityId,
|
|
244
|
+
sourceKind: nextSourceKind,
|
|
245
|
+
sourceConnectionId: nextSourceConnectionId,
|
|
246
|
+
sourceRef: nextSourceRef,
|
|
247
|
+
paxAdult,
|
|
248
|
+
startsAt: pending.startsAt,
|
|
249
|
+
endsAt: pending.endsAt,
|
|
250
|
+
})
|
|
251
|
+
: null,
|
|
252
|
+
});
|
|
253
|
+
}, items: catalogHits, selectedItem: selectedCatalog, getKey: (hit) => hit.id, getLabel: catalogHitLabel, getSecondary: (hit) => catalogHitStringField(hit, "source.kind") ?? undefined, onSearchChange: setCatalogSearch, placeholder: placeholder, emptyText: emptyText, triggerClassName: "w-full", clearable: true }) }), pending.kind === "product" ? (_jsx(ProductDeparturePicker, { slots: departureQuery.data?.rows ?? [], isLoading: departureQuery.isLoading, isError: departureQuery.isError, value: selectedDeparture?.id ?? "", disabled: !pending.catalogEntityId, onChange: (slotId) => {
|
|
254
|
+
const slot = departureQuery.data?.rows.find((candidate) => candidate.id === slotId);
|
|
255
|
+
if (!slot)
|
|
256
|
+
return;
|
|
257
|
+
onChange({
|
|
258
|
+
...pending,
|
|
259
|
+
startsAt: slot.startsAt,
|
|
260
|
+
endsAt: slot.endsAt ?? "",
|
|
261
|
+
bookingDraft: pending.bookingDraft
|
|
262
|
+
? updateDraftDeparture(pending.bookingDraft, slot)
|
|
263
|
+
: null,
|
|
264
|
+
});
|
|
265
|
+
} })) : (_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsx(Field, { label: t.fromLabel, children: _jsx(DateTimeField, { value: pending.startsAt, onChange: (value) => {
|
|
266
|
+
const startsAt = value ?? "";
|
|
267
|
+
onChange({
|
|
268
|
+
...pending,
|
|
269
|
+
startsAt,
|
|
270
|
+
bookingDraft: pending.bookingDraft
|
|
271
|
+
? updateDraftSchedule(pending.bookingDraft, startsAt, pending.endsAt)
|
|
272
|
+
: null,
|
|
273
|
+
});
|
|
274
|
+
} }) }), _jsx(Field, { label: t.toLabel, children: _jsx(DateTimeField, { value: pending.endsAt, onChange: (value) => {
|
|
275
|
+
const endsAt = value ?? "";
|
|
276
|
+
onChange({
|
|
277
|
+
...pending,
|
|
278
|
+
endsAt,
|
|
279
|
+
bookingDraft: pending.bookingDraft
|
|
280
|
+
? updateDraftSchedule(pending.bookingDraft, pending.startsAt, endsAt)
|
|
281
|
+
: null,
|
|
282
|
+
});
|
|
283
|
+
} }) })] })), shape ? (_jsx(CatalogComponentOptions, { draft: pending.bookingDraft, shape: shape, onDraftChange: (bookingDraft) => onChange({ ...pending, bookingDraft }) })) : pending.bookingDraft && quote.isQuoting ? (_jsx("p", { className: "text-muted-foreground text-sm", children: t.loadingOptions })) : null] }));
|
|
284
|
+
}
|
|
285
|
+
function createCatalogBookingDraft({ vertical, entityId, sourceKind, sourceConnectionId, sourceRef, paxAdult, startsAt, endsAt, }) {
|
|
286
|
+
const draft = emptyDraft({
|
|
287
|
+
module: vertical,
|
|
288
|
+
id: entityId,
|
|
289
|
+
sourceKind,
|
|
290
|
+
...(sourceConnectionId ? { sourceConnectionId } : {}),
|
|
291
|
+
...(sourceRef ? { sourceRef } : {}),
|
|
292
|
+
}, { buyerType: "B2C" });
|
|
293
|
+
return updateDraftSchedule({
|
|
294
|
+
...draft,
|
|
295
|
+
configure: { ...draft.configure, pax: { adult: paxAdult } },
|
|
296
|
+
}, startsAt, endsAt);
|
|
297
|
+
}
|
|
298
|
+
function useProductDepartures(productId) {
|
|
299
|
+
const { baseUrl, fetcher } = useVoyantTravelComposerContext();
|
|
300
|
+
return useQuery({
|
|
301
|
+
queryKey: ["admin-trip-composer-product-departures", productId],
|
|
302
|
+
queryFn: async () => {
|
|
303
|
+
if (!productId)
|
|
304
|
+
return { rows: [] };
|
|
305
|
+
const res = await fetcher(`${baseUrl}/v1/admin/catalog/slots?entityModule=products&entityId=${encodeURIComponent(productId)}`);
|
|
306
|
+
if (!res.ok)
|
|
307
|
+
throw new Error(`Departures request failed: ${res.status}`);
|
|
308
|
+
return res.json();
|
|
309
|
+
},
|
|
310
|
+
enabled: Boolean(productId),
|
|
311
|
+
staleTime: 30_000,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function ProductDeparturePicker({ slots, isLoading, isError, value, disabled, onChange, }) {
|
|
315
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
316
|
+
const [search, setSearch] = React.useState("");
|
|
317
|
+
const filteredSlots = React.useMemo(() => {
|
|
318
|
+
const trimmed = search.trim().toLowerCase();
|
|
319
|
+
if (!trimmed)
|
|
320
|
+
return slots;
|
|
321
|
+
return slots.filter((slot) => formatDepartureLabel(slot).toLowerCase().includes(trimmed));
|
|
322
|
+
}, [search, slots]);
|
|
323
|
+
const selectedSlot = slots.find((slot) => slot.id === value) ?? null;
|
|
324
|
+
return (_jsx(Field, { label: t.departureLabel, children: isLoading ? (_jsx("div", { className: "h-10 rounded-md border bg-muted/40" })) : isError ? (_jsx("p", { className: "text-destructive text-sm", children: t.departuresUnavailable })) : slots.length === 0 && !disabled ? (_jsx("p", { className: "text-muted-foreground text-sm", children: t.noUpcomingDepartures })) : (_jsx(AsyncCombobox, { value: value || null, onChange: (slotId) => {
|
|
325
|
+
if (slotId)
|
|
326
|
+
onChange(slotId);
|
|
327
|
+
}, items: filteredSlots, selectedItem: selectedSlot, getKey: (slot) => slot.id, getLabel: formatDepartureLabel, getSecondary: (slot) => slot.timezone, onSearchChange: setSearch, placeholder: t.searchDeparturesPlaceholder, emptyText: t.noDeparturesFound, triggerClassName: "w-full", disabled: disabled || slots.length === 0, clearable: false })) }));
|
|
328
|
+
}
|
|
329
|
+
function updateDraftSchedule(draft, startsAt, endsAt) {
|
|
330
|
+
const departureDate = startsAt ? startsAt.slice(0, 10) : undefined;
|
|
331
|
+
const departureTime = startsAt?.includes("T") ? startsAt.slice(11, 16) : undefined;
|
|
332
|
+
const dateRange = startsAt && endsAt
|
|
333
|
+
? {
|
|
334
|
+
checkIn: startsAt.slice(0, 10),
|
|
335
|
+
checkOut: endsAt.slice(0, 10),
|
|
336
|
+
}
|
|
337
|
+
: undefined;
|
|
338
|
+
return {
|
|
339
|
+
...draft,
|
|
340
|
+
configure: {
|
|
341
|
+
...draft.configure,
|
|
342
|
+
departureDate,
|
|
343
|
+
departureTime,
|
|
344
|
+
dateRange,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function updateDraftDeparture(draft, slot) {
|
|
349
|
+
const scheduledDraft = updateDraftSchedule(draft, slot.startsAt, slot.endsAt ?? "");
|
|
350
|
+
return {
|
|
351
|
+
...scheduledDraft,
|
|
352
|
+
configure: {
|
|
353
|
+
...scheduledDraft.configure,
|
|
354
|
+
departureSlotId: slot.id,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function updateDraftPax(draft, paxAdult) {
|
|
359
|
+
return {
|
|
360
|
+
...draft,
|
|
361
|
+
configure: {
|
|
362
|
+
...draft.configure,
|
|
363
|
+
pax: {
|
|
364
|
+
...draft.configure.pax,
|
|
365
|
+
adult: paxAdult,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function CatalogComponentOptions({ draft, shape, onDraftChange, }) {
|
|
371
|
+
if (!draft)
|
|
372
|
+
return null;
|
|
373
|
+
const hasAccommodation = shape.showsAccommodation &&
|
|
374
|
+
((shape.accommodation?.roomOptions?.length ?? 0) > 0 ||
|
|
375
|
+
(shape.accommodation?.subSteps?.length ?? 0) > 0);
|
|
376
|
+
const productOptionStep = shape.configureSubSteps?.find((step) => step.kind === "product-option");
|
|
377
|
+
const hasAddons = shape.showsAddons &&
|
|
378
|
+
((shape.addons?.catalog?.length ?? 0) > 0 || (shape.addons?.groups?.length ?? 0) > 0);
|
|
379
|
+
if (!productOptionStep && !hasAccommodation && !hasAddons)
|
|
380
|
+
return null;
|
|
381
|
+
return (_jsxs("div", { className: "flex flex-col gap-5 border-t pt-4", children: [productOptionStep ? (_jsx(CatalogProductOptionOptions, { draft: draft, options: productOptionStep.options, onDraftChange: onDraftChange })) : null, hasAccommodation ? (_jsx(CatalogAccommodationOptions, { draft: draft, shape: shape, onDraftChange: onDraftChange })) : null, hasAddons ? (_jsx(CatalogExtrasOptions, { draft: draft, shape: shape, onDraftChange: onDraftChange })) : null] }));
|
|
382
|
+
}
|
|
383
|
+
function CatalogProductOptionOptions({ draft, options, onDraftChange, }) {
|
|
384
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
385
|
+
const selections = draft.configure.optionSelections ?? [];
|
|
386
|
+
if (options.length === 0)
|
|
387
|
+
return null;
|
|
388
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { children: [_jsx("h4", { className: "font-medium text-sm", children: t.optionsHeading }), _jsx("p", { className: "text-muted-foreground text-xs", children: t.optionsHint })] }), _jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: options.map((option) => {
|
|
389
|
+
const unit = option.units?.[0];
|
|
390
|
+
const selected = selections.find((selection) => selection.optionId === option.id);
|
|
391
|
+
const quantity = selected?.quantity ?? 0;
|
|
392
|
+
const maxQuantity = unit?.maxQuantity ?? 99;
|
|
393
|
+
return (_jsx("div", { className: `rounded-md border p-3 text-left text-sm transition-colors ${quantity > 0 ? "border-primary bg-primary/10" : "bg-background"}`, children: _jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("span", { className: "min-w-0", children: [_jsxs("span", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "font-medium", children: option.name }), option.code ? (_jsx("span", { className: "text-muted-foreground text-xs uppercase", children: option.code })) : null, option.isDefault ? _jsx(Badge, { variant: "secondary", children: t.defaultOption }) : null] }), option.description ? (_jsx("span", { className: "mt-1 block text-muted-foreground text-xs", children: option.description })) : null, unit ? (_jsx("span", { className: "mt-1 block text-muted-foreground text-xs", children: unit.name })) : null] }), _jsx(QuantityStepper, { value: quantity, onDecrement: () => {
|
|
394
|
+
const nextQuantity = Math.max(0, quantity - 1);
|
|
395
|
+
onDraftChange(setDraftOptionQuantity(draft, option, unit ?? null, nextQuantity));
|
|
396
|
+
}, onIncrement: () => {
|
|
397
|
+
const nextQuantity = Math.min(maxQuantity, quantity + 1);
|
|
398
|
+
onDraftChange(setDraftOptionQuantity(draft, option, unit ?? null, nextQuantity));
|
|
399
|
+
} })] }) }, option.id));
|
|
400
|
+
}) })] }));
|
|
401
|
+
}
|
|
402
|
+
function setDraftOptionQuantity(draft, option, unit, quantity) {
|
|
403
|
+
const existingSelections = draft.configure.optionSelections ?? [];
|
|
404
|
+
const nextSelections = existingSelections.filter((selection) => selection.optionId !== option.id);
|
|
405
|
+
if (quantity > 0) {
|
|
406
|
+
nextSelections.push({
|
|
407
|
+
optionId: option.id,
|
|
408
|
+
optionName: option.name,
|
|
409
|
+
...(unit ? { optionUnitId: unit.id, optionUnitName: unit.name } : {}),
|
|
410
|
+
quantity,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const firstSelection = nextSelections[0];
|
|
414
|
+
return {
|
|
415
|
+
...draft,
|
|
416
|
+
configure: {
|
|
417
|
+
...draft.configure,
|
|
418
|
+
variantId: firstSelection?.optionId,
|
|
419
|
+
optionSelections: nextSelections,
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
function CatalogAccommodationOptions({ draft, shape, onDraftChange, }) {
|
|
424
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
425
|
+
const rooms = shape.accommodation?.roomOptions ?? [];
|
|
426
|
+
const accommodation = draft.accommodation ?? { rooms: [], travelerAssignments: {} };
|
|
427
|
+
if (rooms.length === 0)
|
|
428
|
+
return null;
|
|
429
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { children: [_jsx("h4", { className: "font-medium text-sm", children: t.roomsHeading }), _jsx("p", { className: "text-muted-foreground text-xs", children: t.roomsHint })] }), _jsx("div", { className: "flex flex-col gap-2", children: rooms.map((room) => {
|
|
430
|
+
const current = accommodation.rooms.find((entry) => entry.optionUnitId === room.id);
|
|
431
|
+
const ratePlans = room.ratePlans ?? [];
|
|
432
|
+
const quantity = current?.quantity ?? 0;
|
|
433
|
+
return (_jsxs("div", { className: "flex flex-col gap-3 rounded-md border bg-muted/20 p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-medium text-sm", children: room.name }), room.description ? (_jsx("p", { className: "text-muted-foreground text-xs", children: room.description })) : null] }), _jsx(QuantityStepper, { value: quantity, onDecrement: () => {
|
|
434
|
+
const nextRooms = accommodation.rooms.filter((entry) => entry.optionUnitId !== room.id);
|
|
435
|
+
const nextQuantity = quantity - 1;
|
|
436
|
+
if (nextQuantity > 0) {
|
|
437
|
+
nextRooms.push({
|
|
438
|
+
optionUnitId: room.id,
|
|
439
|
+
quantity: nextQuantity,
|
|
440
|
+
ratePlanId: current?.ratePlanId,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
onDraftChange(setAccommodation(draft, { ...accommodation, rooms: nextRooms }));
|
|
444
|
+
}, onIncrement: () => {
|
|
445
|
+
const nextRooms = accommodation.rooms.filter((entry) => entry.optionUnitId !== room.id);
|
|
446
|
+
const ratePlanId = current?.ratePlanId ?? (ratePlans.length === 1 ? ratePlans[0]?.id : undefined);
|
|
447
|
+
nextRooms.push({ optionUnitId: room.id, quantity: quantity + 1, ratePlanId });
|
|
448
|
+
onDraftChange(setAccommodation(draft, { ...accommodation, rooms: nextRooms }));
|
|
449
|
+
} })] }), quantity > 0 && ratePlans.length > 0 ? (_jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: ratePlans.map((plan) => {
|
|
450
|
+
const selected = current?.ratePlanId === plan.id;
|
|
451
|
+
return (_jsxs("button", { type: "button", onClick: () => {
|
|
452
|
+
const nextRooms = accommodation.rooms.map((entry) => entry.optionUnitId === room.id
|
|
453
|
+
? { ...entry, ratePlanId: plan.id }
|
|
454
|
+
: entry);
|
|
455
|
+
onDraftChange(setAccommodation(draft, { ...accommodation, rooms: nextRooms }));
|
|
456
|
+
}, className: `rounded-md border p-3 text-left text-sm transition-colors ${selected ? "border-primary bg-primary/10" : "bg-background"}`, children: [_jsx("span", { className: "font-medium", children: plan.name }), plan.description ? (_jsx("span", { className: "mt-1 block text-muted-foreground text-xs", children: plan.description })) : null, plan.inclusions && plan.inclusions.length > 0 ? (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["Includes ", plan.inclusions.join(", ")] })) : null] }, plan.id));
|
|
457
|
+
}) })) : null] }, room.id));
|
|
458
|
+
}) })] }));
|
|
459
|
+
}
|
|
460
|
+
function CatalogExtrasOptions({ draft, shape, onDraftChange, }) {
|
|
461
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
462
|
+
const flat = shape.addons?.catalog ?? [];
|
|
463
|
+
const groups = shape.addons?.groups ?? [];
|
|
464
|
+
const hasGroupedExtras = groups.some((group) => group.items.length > 0);
|
|
465
|
+
if (flat.length === 0 && !hasGroupedExtras)
|
|
466
|
+
return null;
|
|
467
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { children: [_jsx("h4", { className: "font-medium text-sm", children: t.optionsAndExtras }), _jsx("p", { className: "text-muted-foreground text-xs", children: t.optionsAndExtrasHint })] }), _jsxs("div", { className: "flex flex-col gap-3", children: [groups.map((group) => group.items.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("div", { className: "text-muted-foreground text-xs uppercase", children: group.label }), group.items.map((item) => (_jsx(CatalogExtraRow, { draft: draft, item: item, onDraftChange: onDraftChange }, item.id)))] }, group.label)) : null), flat.map((item) => (_jsx(CatalogExtraRow, { draft: draft, item: item, onDraftChange: onDraftChange }, item.id)))] })] }));
|
|
468
|
+
}
|
|
469
|
+
function CatalogExtraRow({ draft, item, onDraftChange, }) {
|
|
470
|
+
const current = draft.addons.find((entry) => entry.extraId === item.id);
|
|
471
|
+
const quantity = current?.quantity ?? 0;
|
|
472
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-3 rounded-md border bg-muted/20 p-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-medium text-sm", children: item.name }), item.description ? (_jsx("p", { className: "text-muted-foreground text-xs", children: item.description })) : null] }), _jsx(QuantityStepper, { value: quantity, onDecrement: () => {
|
|
473
|
+
const nextAddons = draft.addons.filter((entry) => entry.extraId !== item.id);
|
|
474
|
+
const nextQuantity = quantity - 1;
|
|
475
|
+
if (nextQuantity > 0)
|
|
476
|
+
nextAddons.push({ extraId: item.id, quantity: nextQuantity });
|
|
477
|
+
onDraftChange(setAddons(draft, nextAddons));
|
|
478
|
+
}, onIncrement: () => {
|
|
479
|
+
const nextAddons = draft.addons.filter((entry) => entry.extraId !== item.id);
|
|
480
|
+
nextAddons.push({ extraId: item.id, quantity: quantity + 1 });
|
|
481
|
+
onDraftChange(setAddons(draft, nextAddons));
|
|
482
|
+
} })] }));
|
|
483
|
+
}
|
|
484
|
+
function QuantityStepper({ value, onDecrement, onIncrement, }) {
|
|
485
|
+
return (_jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "sm", type: "button", onClick: onDecrement, disabled: value <= 0, children: _jsx(Minus, { className: "size-3.5" }) }), _jsx("span", { className: "min-w-6 text-center text-sm", children: value }), _jsx(Button, { variant: "outline", size: "sm", type: "button", onClick: onIncrement, children: _jsx(Plus, { className: "size-3.5" }) })] }));
|
|
486
|
+
}
|
|
487
|
+
function FlightConfigurator({ pending, travelers, onChange, }) {
|
|
488
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
489
|
+
const passengers = React.useMemo(() => flightPassengersFromTripTravelers(travelers), [travelers]);
|
|
490
|
+
const passengerCounts = React.useMemo(() => passengerCountsFromTripTravelers(travelers), [travelers]);
|
|
491
|
+
const isRoundTrip = pending.tripType === "round_trip";
|
|
492
|
+
const ready = Boolean(pending.origin &&
|
|
493
|
+
pending.destination &&
|
|
494
|
+
pending.departDate &&
|
|
495
|
+
(!isRoundTrip || pending.returnDate));
|
|
496
|
+
const slices = React.useMemo(() => {
|
|
497
|
+
if (!pending.origin || !pending.destination || !pending.departDate)
|
|
498
|
+
return [];
|
|
499
|
+
const next = [
|
|
500
|
+
{
|
|
501
|
+
origin: pending.origin,
|
|
502
|
+
destination: pending.destination,
|
|
503
|
+
departureDate: pending.departDate,
|
|
504
|
+
},
|
|
505
|
+
];
|
|
506
|
+
if (pending.tripType === "round_trip" && pending.returnDate) {
|
|
507
|
+
next.push({
|
|
508
|
+
origin: pending.destination,
|
|
509
|
+
destination: pending.origin,
|
|
510
|
+
departureDate: pending.returnDate,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return next;
|
|
514
|
+
}, [
|
|
515
|
+
pending.departDate,
|
|
516
|
+
pending.destination,
|
|
517
|
+
pending.origin,
|
|
518
|
+
pending.returnDate,
|
|
519
|
+
pending.tripType,
|
|
520
|
+
]);
|
|
521
|
+
const search = useFlightSearch({
|
|
522
|
+
slices,
|
|
523
|
+
passengers: passengerCounts,
|
|
524
|
+
cabin: pending.cabin,
|
|
525
|
+
}, { enabled: ready });
|
|
526
|
+
const offers = search.data?.offers ?? [];
|
|
527
|
+
const selectedOffer = pending.selectedOffer &&
|
|
528
|
+
offers.some((offer) => offer.offerId === pending.selectedOffer?.offerId)
|
|
529
|
+
? pending.selectedOffer
|
|
530
|
+
: pending.selectedOffer;
|
|
531
|
+
const ancillaryQuery = useFlightAncillaries(selectedOffer ? { offerId: selectedOffer.offerId, offer: selectedOffer } : null, { enabled: Boolean(selectedOffer) });
|
|
532
|
+
const ancillaryCatalog = ancillaryQuery.data?.catalog ?? pending.ancillaryCatalog;
|
|
533
|
+
const priced = flightPricingFromPending({
|
|
534
|
+
...pending,
|
|
535
|
+
selectedOffer,
|
|
536
|
+
ancillaryCatalog,
|
|
537
|
+
});
|
|
538
|
+
const patch = (next) => {
|
|
539
|
+
onChange({ ...pending, ...next });
|
|
540
|
+
};
|
|
541
|
+
const resetSelection = (next) => {
|
|
542
|
+
patch({
|
|
543
|
+
selectedOffer: null,
|
|
544
|
+
ancillaryCatalog: null,
|
|
545
|
+
fareBundlePicks: [],
|
|
546
|
+
baggagePicks: [],
|
|
547
|
+
assistancePicks: [],
|
|
548
|
+
extrasPicks: [],
|
|
549
|
+
...next,
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { type: "button", size: "sm", variant: !isRoundTrip ? "default" : "outline", onClick: () => resetSelection({ tripType: "one_way", returnDate: "" }), children: t.flightOneWay }), _jsx(Button, { type: "button", size: "sm", variant: isRoundTrip ? "default" : "outline", onClick: () => resetSelection({ tripType: "round_trip" }), children: t.flightRoundTrip })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsx(Field, { label: t.flightOrigin, children: _jsx(AirportCombobox, { value: pending.origin, placeholder: t.fromPlaceholder, onChange: (code) => resetSelection({ origin: code }), className: "w-full" }) }), _jsx(Field, { label: t.flightDestination, children: _jsx(AirportCombobox, { value: pending.destination, placeholder: t.toPlaceholder, onChange: (code) => resetSelection({ destination: code }), className: "w-full" }) }), _jsx(Field, { label: t.flightDepart, children: _jsx(DatePicker, { value: pending.departDate, onChange: (departDate) => resetSelection({ departDate: departDate ?? "" }), placeholder: t.pickDate }) }), isRoundTrip ? (_jsx(Field, { label: t.flightReturn, children: _jsxs("div", { className: "flex gap-2", children: [_jsx(DatePicker, { value: pending.returnDate, onChange: (returnDate) => resetSelection({ returnDate: returnDate ?? "" }), placeholder: t.pickDate, className: "flex-1" }), _jsx(Button, { type: "button", variant: "outline", size: "icon", "aria-label": t.clearReturnDate, onClick: () => resetSelection({ returnDate: "" }), children: _jsx(X, { className: "size-4" }) })] }) })) : null] }), _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 rounded-md border bg-muted/20 p-3", children: [_jsxs("div", { className: "flex flex-col gap-0.5 text-sm", children: [_jsx("span", { className: "font-medium", children: t.travelersWord }), _jsxs("span", { className: "text-muted-foreground text-xs", children: [passengerCounts.adults === 1
|
|
553
|
+
? t.travelerCountAdultSingular
|
|
554
|
+
: formatMessage(t.travelerCountAdultPlural, { count: passengerCounts.adults }), passengerCounts.children
|
|
555
|
+
? formatMessage(passengerCounts.children === 1 ? t.travelerCountChild : t.travelerCountChildren, { count: passengerCounts.children })
|
|
556
|
+
: "", passengerCounts.infants
|
|
557
|
+
? formatMessage(passengerCounts.infants === 1 ? t.travelerCountInfant : t.travelerCountInfants, { count: passengerCounts.infants })
|
|
558
|
+
: ""] })] }), _jsx(CabinSelector, { value: pending.cabin, onChange: (cabin) => resetSelection({ cabin }) })] }), ready ? (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("h4", { className: "font-medium text-sm", children: t.flightOptionsHeading }), search.isFetching ? (_jsxs("span", { className: "flex items-center gap-1 text-muted-foreground text-xs", children: [_jsx(Loader2, { className: "size-3 animate-spin" }), t.flightSearching] })) : offers.length > 0 ? (_jsx("span", { className: "text-muted-foreground text-xs", children: formatMessage(t.flightOptionsCount, { count: offers.length }) })) : null] }), search.isError ? (_jsx("p", { className: "rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm", children: t.flightSearchFailed })) : null, !search.isFetching && offers.length === 0 && !search.isError ? (_jsx("p", { className: "rounded-md border border-dashed p-3 text-muted-foreground text-sm", children: t.flightNoOptions })) : null, offers.slice(0, 5).map((offer) => (_jsx(FlightOfferRow, { offer: offer, selected: selectedOffer?.offerId === offer.offerId, selectLabel: selectedOffer?.offerId === offer.offerId ? t.flightSelected : t.flightSelect, onSelect: (nextOffer) => patch({
|
|
559
|
+
selectedOffer: nextOffer,
|
|
560
|
+
ancillaryCatalog: null,
|
|
561
|
+
fareBundlePicks: [],
|
|
562
|
+
baggagePicks: [],
|
|
563
|
+
assistancePicks: [],
|
|
564
|
+
extrasPicks: [],
|
|
565
|
+
}) }, offer.offerId)))] })) : (_jsx("p", { className: "rounded-md border border-dashed p-3 text-muted-foreground text-sm", children: t.flightSelectSearchHint })), selectedOffer ? (_jsxs("div", { className: "flex flex-col gap-5 border-t pt-4", children: [_jsx(FlightFareUpsellStep, { outboundOffer: legOffer(selectedOffer, 0), returnOffer: selectedOffer.itineraries[1] ? legOffer(selectedOffer, 1) : undefined, passengers: passengers, passengerCounts: passengerCounts, value: pending.fareBundlePicks, onChange: (fareBundlePicks) => patch({ fareBundlePicks }), sameForAllPassengers: pending.sameFareForAllPassengers, onSameForAllPassengersChange: (sameFareForAllPassengers) => patch({ sameFareForAllPassengers }) }), ancillaryQuery.isError ? (_jsx("p", { className: "rounded-md border border-dashed p-3 text-muted-foreground text-sm", children: t.flightAncillariesUnavailable })) : (_jsx(FlightBaggageStep, { outboundCatalog: ancillaryCatalog, returnCatalog: selectedOffer.itineraries[1] ? ancillaryCatalog : null, outboundOffer: legOffer(selectedOffer, 0), returnOffer: selectedOffer.itineraries[1] ? legOffer(selectedOffer, 1) : undefined, passengers: passengers, passengerCounts: passengerCounts, value: pending.baggagePicks, onChange: (baggagePicks) => patch({ baggagePicks, ancillaryCatalog: ancillaryCatalog ?? null }), sameForBothDirections: pending.sameBaggageBothDirections, onSameForBothDirectionsChange: (sameBaggageBothDirections) => patch({ sameBaggageBothDirections }), loading: ancillaryQuery.isFetching })), ancillaryCatalog ? (_jsx(FlightServicesStep, { outboundCatalog: ancillaryCatalog, returnCatalog: selectedOffer.itineraries[1] ? ancillaryCatalog : null, outboundOffer: legOffer(selectedOffer, 0), returnOffer: selectedOffer.itineraries[1] ? legOffer(selectedOffer, 1) : undefined, passengers: passengers, passengerCounts: passengerCounts, assistance: pending.assistancePicks, extras: pending.extrasPicks, onAssistanceChange: (assistancePicks) => patch({ assistancePicks }), onExtrasChange: (extrasPicks) => patch({ extrasPicks }), loading: ancillaryQuery.isFetching })) : null, _jsxs("div", { className: "flex items-center justify-between rounded-md border bg-muted/30 p-3", children: [_jsx("span", { className: "text-muted-foreground text-sm", children: t.flightTotal }), _jsx("span", { className: "font-semibold text-base", children: formatMoney(priced.totalAmountCents, priced.currency) })] })] })) : null] }));
|
|
566
|
+
}
|
|
567
|
+
function CabinSelector({ value, onChange, }) {
|
|
568
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
569
|
+
const cabins = [
|
|
570
|
+
{ value: "economy", label: t.cabinClasses.economy },
|
|
571
|
+
{ value: "premium_economy", label: t.cabinClasses.premium_economy },
|
|
572
|
+
{ value: "business", label: t.cabinClasses.business },
|
|
573
|
+
{ value: "first", label: t.cabinClasses.first },
|
|
574
|
+
];
|
|
575
|
+
return (_jsx("div", { className: "flex flex-wrap gap-1", children: cabins.map((cabin) => (_jsx(Button, { type: "button", size: "sm", variant: value === cabin.value ? "default" : "outline", onClick: () => onChange(cabin.value), children: cabin.label }, cabin.value))) }));
|
|
576
|
+
}
|
|
577
|
+
export function flightPricingFromPending(pending) {
|
|
578
|
+
const offer = pending.selectedOffer;
|
|
579
|
+
if (!offer) {
|
|
580
|
+
return {
|
|
581
|
+
currency: "EUR",
|
|
582
|
+
subtotalAmountCents: 0,
|
|
583
|
+
taxAmountCents: 0,
|
|
584
|
+
ancillaryAmountCents: 0,
|
|
585
|
+
totalAmountCents: 0,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const currencyCode = offer.totalPrice.currency;
|
|
589
|
+
const offerTotal = moneyToCents(offer.totalPrice.amount);
|
|
590
|
+
const fareTax = offer.fareBreakdowns.reduce((sum, line) => sum + moneyToCents(line.taxes.amount), 0);
|
|
591
|
+
const ancillaryAmount = flightAncillaryAmountCents(pending, currencyCode);
|
|
592
|
+
return {
|
|
593
|
+
currency: currencyCode,
|
|
594
|
+
subtotalAmountCents: Math.max(0, offerTotal - fareTax) + ancillaryAmount,
|
|
595
|
+
taxAmountCents: fareTax,
|
|
596
|
+
ancillaryAmountCents: ancillaryAmount,
|
|
597
|
+
totalAmountCents: offerTotal + ancillaryAmount,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
function flightAncillaryAmountCents(pending, currencyCode) {
|
|
601
|
+
const offer = pending.selectedOffer;
|
|
602
|
+
const catalog = pending.ancillaryCatalog;
|
|
603
|
+
if (!offer)
|
|
604
|
+
return 0;
|
|
605
|
+
const fareBundles = offer.fareBundles ?? [];
|
|
606
|
+
const fareBundleTotal = pending.fareBundlePicks.reduce((sum, pick) => {
|
|
607
|
+
const bundle = fareBundles.find((candidate) => candidate.id === pick.bundleId);
|
|
608
|
+
if (!bundle || bundle.priceDelta.currency !== currencyCode)
|
|
609
|
+
return sum;
|
|
610
|
+
return sum + moneyToCents(bundle.priceDelta.amount);
|
|
611
|
+
}, 0);
|
|
612
|
+
if (!catalog)
|
|
613
|
+
return fareBundleTotal;
|
|
614
|
+
const baggageTotal = pending.baggagePicks.reduce((sum, pick) => {
|
|
615
|
+
const option = catalog.baggage.find((candidate) => candidate.id === pick.optionId);
|
|
616
|
+
if (!option || option.price.currency !== currencyCode)
|
|
617
|
+
return sum;
|
|
618
|
+
return sum + moneyToCents(option.price.amount) * (pick.quantity ?? 1);
|
|
619
|
+
}, 0);
|
|
620
|
+
const assistanceTotal = pending.assistancePicks.reduce((sum, pick) => {
|
|
621
|
+
const option = catalog.assistance.find((candidate) => candidate.id === pick.optionId);
|
|
622
|
+
if (!option?.price || option.price.currency !== currencyCode)
|
|
623
|
+
return sum;
|
|
624
|
+
return sum + moneyToCents(option.price.amount);
|
|
625
|
+
}, 0);
|
|
626
|
+
const extrasTotal = pending.extrasPicks.reduce((sum, pick) => {
|
|
627
|
+
const option = catalog.extras.find((candidate) => candidate.id === pick.optionId);
|
|
628
|
+
if (!option || option.price.currency !== currencyCode)
|
|
629
|
+
return sum;
|
|
630
|
+
return sum + moneyToCents(option.price.amount) * (pick.quantity ?? 1);
|
|
631
|
+
}, 0);
|
|
632
|
+
return fareBundleTotal + baggageTotal + assistanceTotal + extrasTotal;
|
|
633
|
+
}
|
|
634
|
+
function legOffer(offer, index) {
|
|
635
|
+
const itinerary = offer.itineraries[index];
|
|
636
|
+
return itinerary ? { ...offer, itineraries: [itinerary] } : offer;
|
|
637
|
+
}
|
|
638
|
+
function moneyToCents(amount) {
|
|
639
|
+
const parsed = Number.parseFloat(amount);
|
|
640
|
+
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
|
641
|
+
}
|
|
642
|
+
function passengerCountsFromTripTravelers(travelers) {
|
|
643
|
+
const counts = travelers.reduce((acc, traveler) => {
|
|
644
|
+
if (traveler.role === "child")
|
|
645
|
+
acc.children += 1;
|
|
646
|
+
else if (traveler.role === "infant")
|
|
647
|
+
acc.infants += 1;
|
|
648
|
+
else
|
|
649
|
+
acc.adults += 1;
|
|
650
|
+
return acc;
|
|
651
|
+
}, { adults: 0, children: 0, infants: 0 });
|
|
652
|
+
return {
|
|
653
|
+
adults: Math.max(1, counts.adults),
|
|
654
|
+
children: counts.children,
|
|
655
|
+
infants: counts.infants,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function flightPassengersFromTripTravelers(travelers) {
|
|
659
|
+
return travelers.map((traveler, index) => {
|
|
660
|
+
const type = traveler.role === "child" ? "child" : traveler.role === "infant" ? "infant" : "adult";
|
|
661
|
+
return {
|
|
662
|
+
passengerId: traveler.localId || `traveler_${index + 1}`,
|
|
663
|
+
type,
|
|
664
|
+
// fallback names sent verbatim to the flight provider's API as ASCII
|
|
665
|
+
// passenger placeholders when the operator hasn't yet filled in the
|
|
666
|
+
// real traveler.
|
|
667
|
+
firstName:
|
|
668
|
+
// i18n-literal-ok
|
|
669
|
+
traveler.firstName || (type === "adult" ? "Adult" : type === "child" ? "Child" : "Infant"),
|
|
670
|
+
lastName: traveler.lastName || `${index + 1}`,
|
|
671
|
+
dateOfBirth: traveler.dateOfBirth || fallbackDobForPassengerType(type),
|
|
672
|
+
...(traveler.email ? { email: traveler.email } : {}),
|
|
673
|
+
};
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
function fallbackDobForPassengerType(type) {
|
|
677
|
+
if (type === "child")
|
|
678
|
+
return "2016-01-01";
|
|
679
|
+
if (type === "infant")
|
|
680
|
+
return "2025-01-01";
|
|
681
|
+
return "1990-01-01";
|
|
682
|
+
}
|
|
683
|
+
function flightSelectionLabels(offer, ancillaries) {
|
|
684
|
+
if (!offer || !ancillaries)
|
|
685
|
+
return [];
|
|
686
|
+
const labels = [];
|
|
687
|
+
const bundles = offer.fareBundles ?? [];
|
|
688
|
+
const bundleCounts = countById(ancillaries.fareBundle?.map((pick) => pick.bundleId) ?? []);
|
|
689
|
+
for (const [bundleId, quantity] of bundleCounts) {
|
|
690
|
+
const bundle = bundles.find((candidate) => candidate.id === bundleId);
|
|
691
|
+
labels.push(`${quantity} × ${bundle?.label ?? bundleId}`);
|
|
692
|
+
}
|
|
693
|
+
const baggageCount = ancillaries.baggage?.reduce((sum, pick) => sum + (pick.quantity ?? 1), 0) ?? 0;
|
|
694
|
+
if (baggageCount > 0)
|
|
695
|
+
labels.push(`${baggageCount} bag${baggageCount === 1 ? "" : "s"}`);
|
|
696
|
+
const assistanceCount = ancillaries.assistance?.length ?? 0;
|
|
697
|
+
if (assistanceCount > 0) {
|
|
698
|
+
labels.push(`${assistanceCount} assistance request${assistanceCount === 1 ? "" : "s"}`);
|
|
699
|
+
}
|
|
700
|
+
const extrasCount = ancillaries.extras?.reduce((sum, pick) => sum + (pick.quantity ?? 1), 0) ?? 0;
|
|
701
|
+
if (extrasCount > 0)
|
|
702
|
+
labels.push(`${extrasCount} extra${extrasCount === 1 ? "" : "s"}`);
|
|
703
|
+
return labels;
|
|
704
|
+
}
|
|
705
|
+
function countById(values) {
|
|
706
|
+
const counts = new Map();
|
|
707
|
+
for (const value of values)
|
|
708
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
709
|
+
return counts;
|
|
710
|
+
}
|
|
711
|
+
function CruiseConfigurator({ pending, onChange, }) {
|
|
712
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
713
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsx(Field, { label: t.cruisePlaceholder.embarkationDate, children: _jsx(Input, { type: "date", value: pending.embarkationDate, onChange: (event) => onChange({ ...pending, embarkationDate: event.target.value }) }) }), _jsx(Field, { label: t.cruisePlaceholder.cabin, children: _jsx(Input, { value: pending.cabin, placeholder: t.cabinPlaceholder, onChange: (event) => onChange({ ...pending, cabin: event.target.value }) }) })] }), _jsx(Field, { label: t.cruisePlaceholder.description, children: _jsx(Textarea, { rows: 2, value: pending.description, onChange: (event) => onChange({ ...pending, description: event.target.value }) }) }), _jsx(Field, { label: t.cruisePlaceholder.estimatedAmount, children: _jsx(Input, { inputMode: "decimal", value: pending.estimatedAmount, placeholder: "0.00", onChange: (event) => onChange({ ...pending, estimatedAmount: event.target.value }) }) })] }));
|
|
714
|
+
}
|
|
715
|
+
function PlaceholderConfigurator({ pending, onChange, }) {
|
|
716
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
717
|
+
const totals = computePlaceholderTotals(pending.subtotalCents, pending.taxRatePct);
|
|
718
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Field, { label: t.manualPlaceholder.nameLabel, children: _jsx(Input, { value: pending.name, placeholder: t.manualPlaceholder.namePlaceholder, onChange: (event) => onChange({ ...pending, name: event.target.value }) }) }), _jsx(Field, { label: t.manualPlaceholder.descriptionLabel, children: _jsx(Textarea, { rows: 2, value: pending.description, placeholder: t.notesPlaceholder, onChange: (event) => onChange({ ...pending, description: event.target.value }) }) }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsx(Field, { label: t.fromLabel, children: _jsx(DateTimeField, { value: pending.startsAt, onChange: (value) => onChange({ ...pending, startsAt: value ?? "" }) }) }), _jsx(Field, { label: t.toLabel, children: _jsx(DateTimeField, { value: pending.endsAt, onChange: (value) => onChange({ ...pending, endsAt: value ?? "" }) }) })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)_140px]", children: [_jsx(Field, { label: t.manualPlaceholder.currencyLabel, children: _jsx(CurrencyCombobox, { value: pending.currency, onChange: (value) => onChange({ ...pending, currency: value ?? "EUR" }) }) }), _jsx(Field, { label: t.manualPlaceholder.subtotalLabel, children: _jsx(CurrencyInput, { value: pending.subtotalCents, onChange: (value) => onChange({ ...pending, subtotalCents: value }), currency: pending.currency, placeholder: "0.00" }) }), _jsx(Field, { label: t.manualPlaceholder.taxRateLabel, children: _jsxs("div", { className: "relative", children: [_jsx(Input, { inputMode: "decimal", value: pending.taxRatePct, placeholder: "0", onChange: (event) => onChange({ ...pending, taxRatePct: event.target.value }), className: "pr-8" }), _jsx("span", { className: "-translate-y-1/2 pointer-events-none absolute top-1/2 right-3 text-muted-foreground text-xs", children: "%" })] }) })] }), _jsxs("div", { className: "flex flex-col gap-1 rounded-md border bg-muted/30 p-3 text-sm", children: [_jsxs("div", { className: "flex items-center justify-between text-muted-foreground", children: [_jsx("span", { children: t.tax }), _jsx("span", { children: formatMoney(totals.tax, pending.currency) })] }), _jsxs("div", { className: "flex items-center justify-between font-semibold", children: [_jsx("span", { children: t.total }), _jsx("span", { children: formatMoney(totals.total, pending.currency) })] })] })] }));
|
|
719
|
+
}
|
|
720
|
+
export function pendingComponentIsValid(pending) {
|
|
721
|
+
switch (pending.kind) {
|
|
722
|
+
case "product":
|
|
723
|
+
return Boolean(pending.catalogEntityId &&
|
|
724
|
+
pending.catalogSourceKind &&
|
|
725
|
+
pending.bookingDraft?.configure.departureSlotId);
|
|
726
|
+
case "stay":
|
|
727
|
+
return Boolean(pending.catalogEntityId && pending.catalogSourceKind && pending.bookingDraft);
|
|
728
|
+
case "flight":
|
|
729
|
+
return Boolean(pending.origin && pending.destination && pending.departDate && pending.selectedOffer);
|
|
730
|
+
case "cruise":
|
|
731
|
+
return Boolean(pending.embarkationDate);
|
|
732
|
+
case "manual":
|
|
733
|
+
return Boolean(pending.name && pending.subtotalCents && pending.subtotalCents > 0);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
export function ComponentsEmpty() {
|
|
737
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
738
|
+
return (_jsx(Empty, { className: "border bg-card", children: _jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(RouteIcon, {}) }), _jsx(EmptyTitle, { children: t.emptyTimeline }), _jsx(EmptyDescription, { children: t.emptyTimelineHint })] }) }));
|
|
739
|
+
}
|
|
740
|
+
export function newTripTraveler() {
|
|
741
|
+
return {
|
|
742
|
+
localId: `tt_${Math.random().toString(36).slice(2, 10)}`,
|
|
743
|
+
personId: null,
|
|
744
|
+
firstName: "",
|
|
745
|
+
lastName: "",
|
|
746
|
+
email: "",
|
|
747
|
+
dateOfBirth: null,
|
|
748
|
+
role: "adult",
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
export function TripTravelersSection({ value, onChange, billingPersonId, }) {
|
|
752
|
+
function patchAt(localId, patch) {
|
|
753
|
+
onChange(value.map((traveler) => traveler.localId === localId ? { ...traveler, ...patch } : traveler));
|
|
754
|
+
}
|
|
755
|
+
function removeAt(localId) {
|
|
756
|
+
onChange(value.filter((traveler) => traveler.localId !== localId));
|
|
757
|
+
}
|
|
758
|
+
function addTravelerByPersonId(personId) {
|
|
759
|
+
if (value.some((traveler) => traveler.personId === personId))
|
|
760
|
+
return;
|
|
761
|
+
onChange([...value, { ...newTripTraveler(), personId }]);
|
|
762
|
+
}
|
|
763
|
+
const existingPersonIds = new Set(value.map((traveler) => traveler.personId).filter(Boolean));
|
|
764
|
+
// Lead = billing person when they're on the roster; otherwise the first
|
|
765
|
+
// traveler. Keeps the "who is the primary traveler" intent natural without
|
|
766
|
+
// forcing operators to reorder the list.
|
|
767
|
+
const leadLocalId = (billingPersonId && value.find((t) => t.personId === billingPersonId)?.localId) ||
|
|
768
|
+
value[0]?.localId ||
|
|
769
|
+
null;
|
|
770
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
771
|
+
return (_jsxs("section", { className: "flex flex-col gap-3 rounded-md border bg-card p-5", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("h2", { className: "font-medium text-base", children: t.travelersSectionTitle }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => onChange([...value, newTripTraveler()]), children: [_jsx(UserPlus, { className: "size-3.5" }), t.addTravelerLabel] })] }), billingPersonId ? (_jsx(BillingQuickAdd, { billingPersonId: billingPersonId, existingPersonIds: existingPersonIds, onAdd: addTravelerByPersonId })) : null, value.length === 0 ? (_jsxs("p", { className: "text-muted-foreground text-sm", children: [t.noTravelersPrefix, _jsx("span", { className: "font-medium", children: t.addTravelerLabel }), "."] })) : (_jsx("div", { className: "flex flex-col gap-2", children: value.map((traveler) => (_jsx(TripTravelerRow, { traveler: traveler, isLead: traveler.localId === leadLocalId, onPatch: (patch) => patchAt(traveler.localId, patch), onRemove: () => removeAt(traveler.localId) }, traveler.localId))) }))] }));
|
|
772
|
+
}
|
|
773
|
+
function BillingQuickAdd({ billingPersonId, existingPersonIds, onAdd, }) {
|
|
774
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
775
|
+
const billingPersonQuery = usePerson(billingPersonId);
|
|
776
|
+
const relationshipsQuery = usePersonRelationships(billingPersonId);
|
|
777
|
+
const billingPerson = billingPersonQuery.data;
|
|
778
|
+
const billingAlreadyAdded = existingPersonIds.has(billingPersonId);
|
|
779
|
+
const relatedPersonIds = React.useMemo(() => {
|
|
780
|
+
const ids = new Set();
|
|
781
|
+
for (const relationship of relationshipsQuery.data?.data ?? []) {
|
|
782
|
+
const otherId = relationship.fromPersonId === billingPersonId
|
|
783
|
+
? relationship.toPersonId
|
|
784
|
+
: relationship.fromPersonId;
|
|
785
|
+
if (otherId && otherId !== billingPersonId)
|
|
786
|
+
ids.add(otherId);
|
|
787
|
+
}
|
|
788
|
+
return [...ids];
|
|
789
|
+
}, [relationshipsQuery.data?.data, billingPersonId]);
|
|
790
|
+
const hasRelationships = relatedPersonIds.length > 0;
|
|
791
|
+
if (billingAlreadyAdded && !hasRelationships)
|
|
792
|
+
return null;
|
|
793
|
+
const billingName = formatPersonName(billingPerson) ?? t.travelersAddRow.billingPersonFallback;
|
|
794
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [!billingAlreadyAdded ? (_jsxs(Button, { variant: "outline", size: "sm", className: "self-start", onClick: () => onAdd(billingPersonId), children: [_jsx(UserPlus, { className: "size-3.5" }), t.travelersAddRow.addBillingPersonPrefix, billingName, t.travelersAddRow.addBillingPersonSuffix] })) : null, hasRelationships ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("span", { className: "text-muted-foreground text-xs", children: [t.travelersAddRow.fromRelationshipsPrefix, billingName, t.travelersAddRow.fromRelationshipsSuffix] }), _jsx("div", { className: "flex flex-wrap gap-1.5", children: relatedPersonIds.map((personId) => (_jsx(RelatedPersonChip, { personId: personId, billingPersonId: billingPersonId, relationships: relationshipsQuery.data?.data ?? [], disabled: existingPersonIds.has(personId), onAdd: () => onAdd(personId) }, personId))) })] })) : null] }));
|
|
795
|
+
}
|
|
796
|
+
function RelatedPersonChip({ personId, billingPersonId, relationships, disabled, onAdd, }) {
|
|
797
|
+
const personQuery = usePerson(personId);
|
|
798
|
+
const name = formatPersonName(personQuery.data) ?? "—";
|
|
799
|
+
const relation = relationships.find((relationship) => (relationship.fromPersonId === billingPersonId && relationship.toPersonId === personId) ||
|
|
800
|
+
(relationship.toPersonId === billingPersonId && relationship.fromPersonId === personId));
|
|
801
|
+
const kindLabel = relation
|
|
802
|
+
? formatRelationshipKind(relation.toPersonId === billingPersonId && relation.inverseKind
|
|
803
|
+
? relation.inverseKind
|
|
804
|
+
: relation.kind)
|
|
805
|
+
: null;
|
|
806
|
+
return (_jsxs(Button, { variant: "outline", size: "sm", disabled: disabled, onClick: onAdd, className: "h-auto py-1.5", children: [_jsx(UserPlus, { className: "size-3.5" }), _jsx("span", { children: name }), kindLabel ? (_jsx(Badge, { variant: "secondary", className: "ml-1 text-[10px] capitalize", children: kindLabel })) : null] }));
|
|
807
|
+
}
|
|
808
|
+
function formatPersonName(person) {
|
|
809
|
+
if (!person)
|
|
810
|
+
return null;
|
|
811
|
+
const name = [person.firstName, person.lastName]
|
|
812
|
+
.filter((part) => (part ?? "").trim().length > 0)
|
|
813
|
+
.join(" ")
|
|
814
|
+
.trim();
|
|
815
|
+
return name || person.email || null;
|
|
816
|
+
}
|
|
817
|
+
function formatRelationshipKind(kind) {
|
|
818
|
+
return kind.replaceAll("_", " ");
|
|
819
|
+
}
|
|
820
|
+
function TripTravelerRow({ traveler, isLead, onPatch, onRemove, }) {
|
|
821
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
822
|
+
const personQuery = usePerson(traveler.personId ?? undefined, {
|
|
823
|
+
enabled: Boolean(traveler.personId),
|
|
824
|
+
});
|
|
825
|
+
React.useEffect(() => {
|
|
826
|
+
const person = personQuery.data;
|
|
827
|
+
if (!person)
|
|
828
|
+
return;
|
|
829
|
+
const nextDob = person.dateOfBirth ?? null;
|
|
830
|
+
const derivedCategory = deriveTravelerRoleFromDob(nextDob);
|
|
831
|
+
const nextRole = isLead
|
|
832
|
+
? "lead"
|
|
833
|
+
: nextDob
|
|
834
|
+
? derivedCategory
|
|
835
|
+
: traveler.role === "lead"
|
|
836
|
+
? "adult"
|
|
837
|
+
: traveler.role;
|
|
838
|
+
const patch = {};
|
|
839
|
+
if ((person.firstName ?? "") !== traveler.firstName)
|
|
840
|
+
patch.firstName = person.firstName ?? "";
|
|
841
|
+
if ((person.lastName ?? "") !== traveler.lastName)
|
|
842
|
+
patch.lastName = person.lastName ?? "";
|
|
843
|
+
if ((person.email ?? "") !== traveler.email)
|
|
844
|
+
patch.email = person.email ?? "";
|
|
845
|
+
if (nextDob !== traveler.dateOfBirth)
|
|
846
|
+
patch.dateOfBirth = nextDob;
|
|
847
|
+
if (nextRole !== traveler.role)
|
|
848
|
+
patch.role = nextRole;
|
|
849
|
+
if (Object.keys(patch).length > 0)
|
|
850
|
+
onPatch(patch);
|
|
851
|
+
}, [
|
|
852
|
+
personQuery.data,
|
|
853
|
+
isLead,
|
|
854
|
+
onPatch,
|
|
855
|
+
traveler.dateOfBirth,
|
|
856
|
+
traveler.email,
|
|
857
|
+
traveler.firstName,
|
|
858
|
+
traveler.lastName,
|
|
859
|
+
traveler.role,
|
|
860
|
+
]);
|
|
861
|
+
React.useEffect(() => {
|
|
862
|
+
if (isLead && traveler.role !== "lead")
|
|
863
|
+
onPatch({ role: "lead" });
|
|
864
|
+
if (!isLead && traveler.role === "lead") {
|
|
865
|
+
const derived = deriveTravelerRoleFromDob(traveler.dateOfBirth);
|
|
866
|
+
onPatch({ role: derived });
|
|
867
|
+
}
|
|
868
|
+
}, [isLead, onPatch, traveler.dateOfBirth, traveler.role]);
|
|
869
|
+
const [sheetOpen, setSheetOpen] = React.useState(false);
|
|
870
|
+
const [sheetMode, setSheetMode] = React.useState("create");
|
|
871
|
+
const lockedByDob = Boolean(traveler.dateOfBirth);
|
|
872
|
+
const displayCategory = lockedByDob
|
|
873
|
+
? deriveTravelerRoleFromDob(traveler.dateOfBirth)
|
|
874
|
+
: traveler.role === "lead"
|
|
875
|
+
? "adult"
|
|
876
|
+
: traveler.role;
|
|
877
|
+
return (_jsxs("div", { className: "flex flex-col gap-3 rounded-md border p-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(PersonCombobox, { value: traveler.personId, onChange: (personId) => onPatch({ personId }), placeholder: t.personPickerPlaceholder }) }), traveler.personId && personQuery.data ? (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
|
|
878
|
+
setSheetMode("edit");
|
|
879
|
+
setSheetOpen(true);
|
|
880
|
+
}, children: [_jsx(Pencil, { className: "size-3.5" }), t.travelerRow.editAction] })) : null, _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
|
|
881
|
+
setSheetMode("create");
|
|
882
|
+
setSheetOpen(true);
|
|
883
|
+
}, children: [_jsx(UserPlus, { className: "size-3.5" }), t.travelerRow.newAction] })] }), _jsx(Sheet, { open: sheetOpen, onOpenChange: setSheetOpen, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: sheetMode === "edit" ? t.travelerRow.editPerson : t.travelerRow.createPerson }) }), _jsx(SheetBody, { children: _jsx(PersonForm, { mode: sheetMode === "edit" && personQuery.data
|
|
884
|
+
? { kind: "edit", person: personQuery.data }
|
|
885
|
+
: { kind: "create" }, onCancel: () => setSheetOpen(false), onSuccess: (person) => {
|
|
886
|
+
onPatch({ personId: person.id });
|
|
887
|
+
setSheetOpen(false);
|
|
888
|
+
} }) })] }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [isLead ? (_jsx(Badge, { children: t.leadBadge })) : (_jsxs(_Fragment, { children: [_jsx(CategoryToggle, { value: displayCategory, onChange: (role) => onPatch({ role }), disabled: lockedByDob }), lockedByDob ? (_jsxs("span", { className: "text-muted-foreground text-xs", children: ["Auto from DOB (", formatDateOnly(traveler.dateOfBirth), ")"] })) : (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("button", { type: "button", "aria-label": t.categoryManualAria, className: "text-muted-foreground hover:text-foreground" }), children: _jsx(Info, { className: "size-3.5" }) }), _jsx(TooltipContent, { children: t.travelerRow.manualCategoryHint })] }))] })), _jsx(Button, { variant: "ghost", size: "sm", className: "ml-auto", onClick: onRemove, "aria-label": t.removeTraveler, children: _jsx(Trash2, { className: "size-3.5" }) })] })] }));
|
|
889
|
+
}
|
|
890
|
+
function CategoryToggle({ value, onChange, disabled, }) {
|
|
891
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
892
|
+
const options = [
|
|
893
|
+
{ value: "adult", label: t.travelerRow.categoryAdult },
|
|
894
|
+
{ value: "child", label: t.travelerRow.categoryChild },
|
|
895
|
+
{ value: "infant", label: t.travelerRow.categoryInfant },
|
|
896
|
+
];
|
|
897
|
+
return (_jsx("div", { className: "flex gap-1", children: options.map((option) => (_jsx(Button, { size: "sm", variant: value === option.value ? "default" : "outline", onClick: () => onChange(option.value), disabled: disabled, children: option.label }, option.value))) }));
|
|
898
|
+
}
|
|
899
|
+
function formatDateOnly(iso) {
|
|
900
|
+
if (!iso)
|
|
901
|
+
return "";
|
|
902
|
+
const parsed = new Date(iso);
|
|
903
|
+
if (Number.isNaN(parsed.getTime()))
|
|
904
|
+
return iso;
|
|
905
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
906
|
+
year: "numeric",
|
|
907
|
+
month: "short",
|
|
908
|
+
day: "numeric",
|
|
909
|
+
}).format(parsed);
|
|
910
|
+
}
|
|
911
|
+
export function CommittedComponentCard({ component, selectable = false, selected = false, onSelectedChange, onRemove, removePending = false, bookingSetupEditable = false, bookingSetupSaving = false, onBookingSetupChange, }) {
|
|
912
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
913
|
+
const Icon = componentIcon(component);
|
|
914
|
+
const coverUrl = componentThumbnailFor(component);
|
|
915
|
+
const componentName = componentTitleFor(component);
|
|
916
|
+
const optionSummary = componentOptionSummaryFor(component);
|
|
917
|
+
const bookingSetup = componentBookingSetupFor(component);
|
|
918
|
+
const canEditBookingSetup = bookingSetupEditable && component.kind === "catalog_booking" && !component.bookingId;
|
|
919
|
+
return (_jsxs("div", { className: `flex flex-col gap-3 rounded-md border bg-card p-4 ${selected ? "ring-2 ring-destructive/40" : ""}`, children: [_jsxs("div", { className: "flex items-start gap-3", children: [selectable ? (_jsx(Checkbox, { className: "mt-1", checked: selected, onCheckedChange: (value) => onSelectedChange?.(Boolean(value)), "aria-label": `Select ${componentName} for cancellation` })) : null, coverUrl ? (_jsx("img", { src: coverUrl, alt: "", className: "size-12 shrink-0 rounded-md object-cover ring-1 ring-border", loading: "lazy" })) : (_jsx("div", { className: "flex size-12 shrink-0 items-center justify-center rounded-md bg-muted", children: _jsx(Icon, { className: "size-5 text-muted-foreground" }) })), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "font-medium", children: componentName }), component.status === "failed" ? (_jsx(Badge, { variant: "destructive", children: t.committedCard.statusFailed })) : component.status === "cancelled" ? (_jsx(Badge, { variant: "secondary", children: t.committedCard.statusCancelled })) : null] }), (() => {
|
|
920
|
+
const label = formatScheduleLabel(component);
|
|
921
|
+
return label ? (_jsxs("p", { className: "flex items-center gap-1 text-muted-foreground text-sm", children: [_jsx(CalendarClock, { className: "size-3.5" }), label] })) : null;
|
|
922
|
+
})(), component.description ? (_jsx("p", { className: "truncate text-muted-foreground text-sm", children: component.description })) : null, optionSummary ? (_jsx("p", { className: "truncate text-muted-foreground text-sm", children: optionSummary })) : null, _jsxs("div", { className: "flex flex-wrap gap-x-4 gap-y-1 text-muted-foreground text-xs", children: [_jsx(Reference, { label: t.committedCard.bookingLabel, value: component.bookingId, href: component.bookingId ? `/bookings/${component.bookingId}` : undefined }), _jsx(Reference, { label: t.committedCard.orderLabel, value: component.orderId }), _jsx(Reference, { label: t.committedCard.paymentLabel, value: component.paymentSessionId })] })] }), _jsxs("div", { className: "flex flex-col items-end gap-1 text-right", children: [_jsx("p", { className: "font-semibold", children: formatMoney(component.componentTotalAmountCents, component.componentCurrency) }), component.componentTaxAmountCents != null && component.componentTaxAmountCents > 0 ? (_jsxs("p", { className: "text-muted-foreground text-xs", children: ["tax ", formatMoney(component.componentTaxAmountCents, component.componentCurrency)] })) : null, onRemove ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: onRemove, disabled: removePending, "aria-label": t.removeComponent, className: "text-muted-foreground hover:text-destructive", children: removePending ? (_jsx(Loader2, { className: "size-3.5 animate-spin" })) : (_jsx(Trash2, { className: "size-3.5" })) })) : null] })] }), canEditBookingSetup ? (_jsxs("div", { className: "flex flex-col gap-3 border-t pt-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "font-medium text-sm", children: t.bookingSetupHeading }), _jsx("p", { className: "text-muted-foreground text-xs", children: t.committedCard.bookingSetupHint })] }), bookingSetupSaving ? (_jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" })) : null] }), _jsx(PaymentScheduleSection, { value: bookingSetup.paymentSchedule, onChange: (paymentSchedule) => onBookingSetupChange?.(component, { ...bookingSetup, paymentSchedule }), currency: component.componentCurrency ?? undefined, totalAmountCents: component.componentTotalAmountCents ?? undefined, labels: { heading: t.committedCard.paymentScheduleHeading } }), _jsxs("div", { className: "grid gap-2 sm:grid-cols-2", children: [_jsx(ComponentSetupCheckbox, { id: `${component.id}-contract-document`, checked: bookingSetup.generateContractDocument, label: t.committedCard.generateContract, onCheckedChange: (generateContractDocument) => onBookingSetupChange?.(component, { ...bookingSetup, generateContractDocument }) }), _jsx(ComponentSetupCheckbox, { id: `${component.id}-invoice-document`, checked: bookingSetup.generateInvoiceDocument, label: t.committedCard.generateInvoice, onCheckedChange: (generateInvoiceDocument) => onBookingSetupChange?.(component, { ...bookingSetup, generateInvoiceDocument }) })] })] })) : component.bookingId ? (_jsx("p", { className: "border-t pt-3 text-muted-foreground text-xs", children: t.committedCard.committedFooter })) : null, (() => {
|
|
923
|
+
const visibleCodes = component.warningCodes.filter(isUserVisibleWarning);
|
|
924
|
+
if (visibleCodes.length === 0)
|
|
925
|
+
return null;
|
|
926
|
+
return (_jsxs("p", { className: "flex items-center gap-1 text-amber-600 text-xs", children: [_jsx(CircleAlert, { className: "size-3" }), visibleCodes.join(", ")] }));
|
|
927
|
+
})()] }));
|
|
928
|
+
}
|
|
929
|
+
function ComponentSetupCheckbox({ id, checked, label, onCheckedChange, }) {
|
|
930
|
+
return (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: id, checked: checked, onCheckedChange: (value) => onCheckedChange(value === true) }), _jsx(Label, { htmlFor: id, className: "cursor-pointer text-sm", children: label })] }));
|
|
931
|
+
}
|
|
932
|
+
export function componentBookingSetupFor(component) {
|
|
933
|
+
const metadata = recordFromUnknown(component.metadata);
|
|
934
|
+
const bookingSetup = recordFromUnknown(metadata?.bookingSetup);
|
|
935
|
+
const draft = recordFromUnknown(metadata?.bookingDraftV1);
|
|
936
|
+
const draftDocumentGeneration = recordFromUnknown(draft?.documentGeneration);
|
|
937
|
+
const setupDocumentGeneration = recordFromUnknown(bookingSetup?.documentGeneration);
|
|
938
|
+
const documentGeneration = setupDocumentGeneration ?? draftDocumentGeneration;
|
|
939
|
+
return {
|
|
940
|
+
paymentSchedule: paymentScheduleValueFromUnknown(bookingSetup?.paymentSchedule),
|
|
941
|
+
generateContractDocument: documentGeneration?.contractDocument === true,
|
|
942
|
+
generateInvoiceDocument: documentGeneration?.invoiceDocument === true,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
export function TripPreviewRail({ trip, pendingCount, travelers, billing, billingPersonId, voucher, onVoucherChange, paymentCurrency, }) {
|
|
946
|
+
const envelope = trip?.envelope;
|
|
947
|
+
const aggregate = envelope?.aggregatePricingSnapshot;
|
|
948
|
+
const components = React.useMemo(() => sortComponentsBySchedule((trip?.components ?? []).filter((component) => component.status !== "removed")), [trip?.components]);
|
|
949
|
+
const status = envelope?.status;
|
|
950
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
951
|
+
return (_jsxs("div", { className: "flex flex-col gap-4 rounded-md border bg-muted/10 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(PreviewLabel, { children: t.tripPreviewLabel }), _jsx(Badge, { variant: "outline", className: "text-[10px] capitalize", children: status ?? "draft" })] }), components.length === 0 && pendingCount === 0 ? (_jsx("p", { className: "text-muted-foreground text-xs", children: t.previewRail.empty })) : null, components.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-3", children: components.map((component) => (_jsx("li", { children: _jsx(PreviewComponentRow, { component: component }) }, component.id))) })) : null, pendingCount > 0 ? (_jsx("p", { className: "text-muted-foreground text-xs", children: pendingCount === 1
|
|
952
|
+
? t.previewRail.pendingComponentsSingular
|
|
953
|
+
: formatMessage(t.previewRail.pendingComponentsPlural, { count: pendingCount }) })) : null, components.length > 0 ? _jsx(CurrencyTotals, { components: components }) : null, _jsxs("div", { className: "flex items-center justify-between border-t pt-3 text-sm", children: [_jsx(PreviewLabel, { children: t.paymentCurrencyLabel }), _jsx("span", { className: "font-medium", children: paymentCurrency })] }), _jsx(BillingPreview, { billing: billing }), _jsx(TravelersPreview, { travelers: travelers, billingPersonId: billingPersonId ?? null }), (() => {
|
|
954
|
+
const warnings = (aggregate?.warnings ?? []).filter(isUserVisibleWarning);
|
|
955
|
+
if (warnings.length === 0)
|
|
956
|
+
return null;
|
|
957
|
+
return (_jsxs(Alert, { children: [_jsx(AlertTriangle, { className: "size-4" }), _jsx(AlertTitle, { children: t.pricingWarningsTitle }), _jsx(AlertDescription, { children: warnings.join(", ") })] }));
|
|
958
|
+
})(), components.length > 0 ? (_jsx("div", { className: "flex flex-col gap-4 border-t pt-3", children: _jsx(VoucherPickerSection, { value: voucher, onChange: onVoucherChange, currency: paymentCurrency, amountCents: aggregate?.totalAmountCents ?? undefined }) })) : null] }));
|
|
959
|
+
}
|
|
960
|
+
function PreviewComponentRow({ component }) {
|
|
961
|
+
const Icon = componentIcon(component);
|
|
962
|
+
const coverUrl = componentThumbnailFor(component);
|
|
963
|
+
const name = componentTitleFor(component);
|
|
964
|
+
const optionSummary = componentOptionSummaryFor(component);
|
|
965
|
+
return (_jsxs("div", { className: "flex items-start gap-3", children: [coverUrl ? (_jsx("img", { src: coverUrl, alt: "", className: "size-12 shrink-0 rounded-md object-cover ring-1 ring-border", loading: "lazy" })) : (_jsx("div", { className: "flex size-12 shrink-0 items-center justify-center rounded-md bg-muted", children: _jsx(Icon, { className: "size-5 text-muted-foreground" }) })), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("span", { className: "truncate font-medium text-sm", children: name }), (() => {
|
|
966
|
+
const label = formatScheduleLabel(component);
|
|
967
|
+
return label ? (_jsx("span", { className: "truncate text-muted-foreground text-xs", children: label })) : null;
|
|
968
|
+
})(), optionSummary ? (_jsx("span", { className: "truncate text-muted-foreground text-xs", children: optionSummary })) : null] }), _jsx("span", { className: "shrink-0 font-medium text-sm", children: formatMoney(component.componentTotalAmountCents, component.componentCurrency) })] }));
|
|
969
|
+
}
|
|
970
|
+
function BillingPreview({ billing }) {
|
|
971
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
972
|
+
const personQuery = usePerson(billing.personId || undefined, {
|
|
973
|
+
enabled: billing.mode === "existing" && Boolean(billing.personId),
|
|
974
|
+
});
|
|
975
|
+
const orgQuery = useOrganization(billing.organizationId ?? undefined, {
|
|
976
|
+
enabled: billing.billTo === "organization" && Boolean(billing.organizationId),
|
|
977
|
+
});
|
|
978
|
+
const display = resolveBillingDisplay(billing, personQuery.data, orgQuery.data, t);
|
|
979
|
+
if (!display.primary && !display.secondary)
|
|
980
|
+
return null;
|
|
981
|
+
return (_jsxs("div", { className: "flex flex-col gap-0.5 border-t pt-3", children: [_jsx(PreviewLabel, { children: t.billingLabel }), _jsx("span", { className: "truncate text-sm", children: display.primary || "—" }), display.secondary ? (_jsx("span", { className: "truncate text-muted-foreground text-xs", children: display.secondary })) : null] }));
|
|
982
|
+
}
|
|
983
|
+
function TravelersPreview({ travelers, billingPersonId, }) {
|
|
984
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
985
|
+
if (travelers.length === 0)
|
|
986
|
+
return null;
|
|
987
|
+
const leadLocalId = (billingPersonId &&
|
|
988
|
+
travelers.find((traveler) => traveler.personId === billingPersonId)?.localId) ||
|
|
989
|
+
travelers[0]?.localId ||
|
|
990
|
+
null;
|
|
991
|
+
return (_jsxs("div", { className: "flex flex-col gap-1 border-t pt-3", children: [_jsx(PreviewLabel, { children: formatMessage(t.travelersWithCount, { count: travelers.length }) }), _jsx("ul", { className: "flex flex-col gap-0.5 text-sm", children: travelers.map((traveler, idx) => (_jsx(TravelerPreviewRow, { traveler: traveler, index: idx, isLead: traveler.localId === leadLocalId }, traveler.localId))) })] }));
|
|
992
|
+
}
|
|
993
|
+
function TravelerPreviewRow({ traveler, index, isLead, }) {
|
|
994
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
995
|
+
const personQuery = usePerson(traveler.personId || undefined, {
|
|
996
|
+
enabled: Boolean(traveler.personId),
|
|
997
|
+
});
|
|
998
|
+
const inlineName = [traveler.firstName, traveler.lastName]
|
|
999
|
+
.filter((part) => part.trim().length > 0)
|
|
1000
|
+
.join(" ")
|
|
1001
|
+
.trim();
|
|
1002
|
+
const name = inlineName ||
|
|
1003
|
+
formatPersonName(personQuery.data) ||
|
|
1004
|
+
formatMessage(t.travelerNumberedFallback, { number: index + 1 });
|
|
1005
|
+
return (_jsxs("li", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { className: "truncate", children: name }), _jsx("span", { className: "shrink-0 text-muted-foreground text-xs capitalize", children: isLead ? t.leadBadge : traveler.role })] }));
|
|
1006
|
+
}
|
|
1007
|
+
function CurrencyTotals({ components }) {
|
|
1008
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
1009
|
+
const buckets = React.useMemo(() => aggregateByCurrency(components), [components]);
|
|
1010
|
+
if (buckets.length === 0)
|
|
1011
|
+
return null;
|
|
1012
|
+
return (_jsx("div", { className: "flex flex-col gap-4 border-t pt-3 text-sm", children: buckets.map((bucket) => (_jsxs("div", { className: "flex flex-col gap-1", children: [buckets.length > 1 ? _jsx(PreviewLabel, { children: bucket.currency }) : null, _jsxs("div", { className: "flex items-center justify-between text-muted-foreground", children: [_jsx("span", { children: t.subtotal }), _jsx("span", { children: formatMoney(bucket.subtotal, bucket.currency) })] }), _jsxs("div", { className: "flex items-center justify-between text-muted-foreground", children: [_jsx("span", { children: t.tax }), _jsx("span", { children: formatMoney(bucket.tax, bucket.currency) })] }), _jsxs("div", { className: "mt-0.5 flex items-center justify-between font-semibold", children: [_jsx("span", { children: t.total }), _jsx("span", { className: "text-lg", children: formatMoney(bucket.total, bucket.currency) })] })] }, bucket.currency))) }));
|
|
1013
|
+
}
|
|
1014
|
+
function aggregateByCurrency(components) {
|
|
1015
|
+
const map = new Map();
|
|
1016
|
+
for (const component of components) {
|
|
1017
|
+
const code = component.componentCurrency;
|
|
1018
|
+
if (!code)
|
|
1019
|
+
continue;
|
|
1020
|
+
const entry = map.get(code) ?? { currency: code, subtotal: 0, tax: 0, total: 0 };
|
|
1021
|
+
entry.subtotal += component.componentSubtotalAmountCents ?? 0;
|
|
1022
|
+
entry.tax += component.componentTaxAmountCents ?? 0;
|
|
1023
|
+
entry.total += component.componentTotalAmountCents ?? 0;
|
|
1024
|
+
map.set(code, entry);
|
|
1025
|
+
}
|
|
1026
|
+
return [...map.values()].sort((a, b) => b.total - a.total);
|
|
1027
|
+
}
|
|
1028
|
+
export function PrimaryAction({ status, componentCount, isBusy, pricePending, reservePending, onReserve, }) {
|
|
1029
|
+
const t = useAdminMessages().trips.adminComposer.panels;
|
|
1030
|
+
if (status === "checkout_started" || status === "booked") {
|
|
1031
|
+
return (_jsx("div", { className: "rounded-md border bg-card p-3 text-center text-muted-foreground text-sm", children: status === "booked" ? t.primaryAction.tripBooked : t.primaryAction.tripCheckoutInProgress }));
|
|
1032
|
+
}
|
|
1033
|
+
if (status === "reserved") {
|
|
1034
|
+
return (_jsx("div", { className: "rounded-md border bg-card p-3 text-center text-muted-foreground text-sm", children: t.primaryAction.tripReserved }));
|
|
1035
|
+
}
|
|
1036
|
+
if (componentCount === 0) {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
if (pricePending) {
|
|
1040
|
+
return (_jsxs(Button, { disabled: true, className: "w-full", children: [_jsx(Loader2, { className: "size-4 animate-spin" }), t.primaryAction.pricingTrip] }));
|
|
1041
|
+
}
|
|
1042
|
+
if (status === "reserve_in_progress" || reservePending) {
|
|
1043
|
+
return (_jsxs(Button, { disabled: true, className: "w-full", children: [_jsx(Loader2, { className: "size-4 animate-spin" }), t.primaryAction.reservingTrip] }));
|
|
1044
|
+
}
|
|
1045
|
+
// `failed` lands here after a reserve attempt errored — allow retry. `priced`
|
|
1046
|
+
// is the happy-path entry into reserve. Any other status (e.g. `draft`)
|
|
1047
|
+
// means pricing hasn't run yet — gate the button until that catches up.
|
|
1048
|
+
const canReserve = status === "priced" || status === "failed";
|
|
1049
|
+
return (_jsxs(Button, { onClick: onReserve, disabled: isBusy || !canReserve, className: "w-full", children: [_jsx(Check, { className: "size-4" }), status === "failed" ? t.primaryAction.retryReserve : t.primaryAction.reserveAndCreateLink] }));
|
|
1050
|
+
}
|
|
1051
|
+
function PreviewLabel({ children }) {
|
|
1052
|
+
return (_jsx("span", { className: "font-medium text-[11px] text-muted-foreground uppercase tracking-wider", children: children }));
|
|
1053
|
+
}
|
|
1054
|
+
function resolveBillingDisplay(billing, person, org, messages) {
|
|
1055
|
+
if (billing.billTo === "organization" && org?.name) {
|
|
1056
|
+
return { primary: org.name, secondary: messages.billingPreview.organizationSecondary };
|
|
1057
|
+
}
|
|
1058
|
+
if (billing.mode === "new") {
|
|
1059
|
+
const name = [billing.newPerson.firstName, billing.newPerson.lastName]
|
|
1060
|
+
.filter((part) => part.trim().length > 0)
|
|
1061
|
+
.join(" ")
|
|
1062
|
+
.trim();
|
|
1063
|
+
return {
|
|
1064
|
+
primary: name || billing.newPerson.email.trim(),
|
|
1065
|
+
secondary: name ? billing.newPerson.email.trim() : "",
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
if (person) {
|
|
1069
|
+
const name = [person.firstName, person.lastName]
|
|
1070
|
+
.filter((part) => (part ?? "").trim().length > 0)
|
|
1071
|
+
.join(" ")
|
|
1072
|
+
.trim();
|
|
1073
|
+
return { primary: name || (person.email ?? ""), secondary: name ? (person.email ?? "") : "" };
|
|
1074
|
+
}
|
|
1075
|
+
return { primary: "", secondary: "" };
|
|
1076
|
+
}
|
|
1077
|
+
function Reference({ label, value, href, }) {
|
|
1078
|
+
if (!value)
|
|
1079
|
+
return null;
|
|
1080
|
+
if (!href)
|
|
1081
|
+
return (_jsxs("span", { children: [label, ": ", _jsxs("span", { className: "font-mono", children: [value.slice(0, 12), "\u2026"] })] }));
|
|
1082
|
+
return (_jsxs("a", { className: "inline-flex items-center gap-1 text-primary", href: href, children: [label, ": ", _jsxs("span", { className: "font-mono", children: [value.slice(0, 12), "\u2026"] }), _jsx(ExternalLink, { className: "size-3" })] }));
|
|
1083
|
+
}
|
|
1084
|
+
function componentIcon(component) {
|
|
1085
|
+
if (component.kind === "flight_placeholder" || component.kind === "flight_order")
|
|
1086
|
+
return Plane;
|
|
1087
|
+
if (component.entityModule === "accommodations")
|
|
1088
|
+
return BedDouble;
|
|
1089
|
+
if (component.kind === "manual_placeholder" || component.kind === "external_order")
|
|
1090
|
+
return Landmark;
|
|
1091
|
+
return RouteIcon;
|
|
1092
|
+
}
|
|
1093
|
+
function verticalIconFor(kind) {
|
|
1094
|
+
if (kind === "product")
|
|
1095
|
+
return RouteIcon;
|
|
1096
|
+
if (kind === "stay")
|
|
1097
|
+
return BedDouble;
|
|
1098
|
+
if (kind === "flight")
|
|
1099
|
+
return Plane;
|
|
1100
|
+
if (kind === "cruise")
|
|
1101
|
+
return Sailboat;
|
|
1102
|
+
return Wrench;
|
|
1103
|
+
}
|
|
1104
|
+
function verticalLabelFor(kind, messages) {
|
|
1105
|
+
const found = verticalsFor(messages).find((vertical) => vertical.kind === kind);
|
|
1106
|
+
return found?.label ?? kind;
|
|
1107
|
+
}
|
|
1108
|
+
export function readComponentSchedule(component) {
|
|
1109
|
+
const md = component.metadata;
|
|
1110
|
+
if (md?.scheduledStartsAt) {
|
|
1111
|
+
return { start: md.scheduledStartsAt, end: md.scheduledEndsAt ?? null };
|
|
1112
|
+
}
|
|
1113
|
+
const dateRange = md?.bookingDraftV1?.configure?.dateRange;
|
|
1114
|
+
if (dateRange?.checkIn) {
|
|
1115
|
+
return { start: dateRange.checkIn, end: dateRange.checkOut ?? null };
|
|
1116
|
+
}
|
|
1117
|
+
const flight = md?.flightDraft;
|
|
1118
|
+
if (flight?.departDate) {
|
|
1119
|
+
return { start: flight.departDate, end: flight.returnDate ?? null };
|
|
1120
|
+
}
|
|
1121
|
+
const cruise = md?.cruiseDraft;
|
|
1122
|
+
if (cruise?.embarkationDate) {
|
|
1123
|
+
return { start: cruise.embarkationDate, end: null };
|
|
1124
|
+
}
|
|
1125
|
+
return { start: null, end: null };
|
|
1126
|
+
}
|
|
1127
|
+
export function formatScheduleLabel(component) {
|
|
1128
|
+
const { start, end } = readComponentSchedule(component);
|
|
1129
|
+
if (!start)
|
|
1130
|
+
return null;
|
|
1131
|
+
const startLabel = formatDateTime(start);
|
|
1132
|
+
if (!end || end === start)
|
|
1133
|
+
return startLabel;
|
|
1134
|
+
return `${startLabel} → ${formatDateTime(end)}`;
|
|
1135
|
+
}
|
|
1136
|
+
export function sortComponentsBySchedule(components) {
|
|
1137
|
+
return [...components].sort((a, b) => {
|
|
1138
|
+
const aStart = readComponentSchedule(a).start;
|
|
1139
|
+
const bStart = readComponentSchedule(b).start;
|
|
1140
|
+
const aMs = aStart ? new Date(aStart).getTime() : Number.NaN;
|
|
1141
|
+
const bMs = bStart ? new Date(bStart).getTime() : Number.NaN;
|
|
1142
|
+
// Components without a schedule fall to the bottom, then ordered by sequence.
|
|
1143
|
+
const aMissing = Number.isNaN(aMs);
|
|
1144
|
+
const bMissing = Number.isNaN(bMs);
|
|
1145
|
+
if (aMissing && bMissing)
|
|
1146
|
+
return a.sequence - b.sequence;
|
|
1147
|
+
if (aMissing)
|
|
1148
|
+
return 1;
|
|
1149
|
+
if (bMissing)
|
|
1150
|
+
return -1;
|
|
1151
|
+
if (aMs !== bMs)
|
|
1152
|
+
return aMs - bMs;
|
|
1153
|
+
return a.sequence - b.sequence;
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
export function readPendingSchedule(pending) {
|
|
1157
|
+
if (pending.kind === "product" || pending.kind === "stay") {
|
|
1158
|
+
return { start: pending.startsAt || null, end: pending.endsAt || null };
|
|
1159
|
+
}
|
|
1160
|
+
if (pending.kind === "flight") {
|
|
1161
|
+
const offerSchedule = readFlightOfferSchedule(pending.selectedOffer);
|
|
1162
|
+
if (offerSchedule.start)
|
|
1163
|
+
return offerSchedule;
|
|
1164
|
+
return { start: pending.departDate || null, end: pending.returnDate || null };
|
|
1165
|
+
}
|
|
1166
|
+
if (pending.kind === "cruise") {
|
|
1167
|
+
return { start: pending.embarkationDate || null, end: null };
|
|
1168
|
+
}
|
|
1169
|
+
return { start: pending.startsAt || null, end: pending.endsAt || null };
|
|
1170
|
+
}
|
|
1171
|
+
function readFlightOfferSchedule(offer) {
|
|
1172
|
+
if (!offer)
|
|
1173
|
+
return { start: null, end: null };
|
|
1174
|
+
const firstItinerary = offer.itineraries[0];
|
|
1175
|
+
const lastItinerary = offer.itineraries[offer.itineraries.length - 1];
|
|
1176
|
+
const firstSegment = firstItinerary?.segments[0];
|
|
1177
|
+
const lastSegment = lastItinerary?.segments[lastItinerary.segments.length - 1];
|
|
1178
|
+
return {
|
|
1179
|
+
start: firstSegment?.departure.at ?? null,
|
|
1180
|
+
end: lastSegment?.arrival.at ?? null,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
function toRange(start, end) {
|
|
1184
|
+
if (!start)
|
|
1185
|
+
return null;
|
|
1186
|
+
const startMs = new Date(start).getTime();
|
|
1187
|
+
if (Number.isNaN(startMs))
|
|
1188
|
+
return null;
|
|
1189
|
+
const endMs = end ? new Date(end).getTime() : startMs;
|
|
1190
|
+
if (Number.isNaN(endMs))
|
|
1191
|
+
return [startMs, startMs];
|
|
1192
|
+
return [startMs, Math.max(endMs, startMs)];
|
|
1193
|
+
}
|
|
1194
|
+
export function findOverlappingComponent(pending, committed) {
|
|
1195
|
+
const { start: pStart, end: pEnd } = readPendingSchedule(pending);
|
|
1196
|
+
const pendingRange = toRange(pStart, pEnd);
|
|
1197
|
+
if (!pendingRange)
|
|
1198
|
+
return null;
|
|
1199
|
+
for (const component of committed) {
|
|
1200
|
+
const { start, end } = readComponentSchedule(component);
|
|
1201
|
+
const range = toRange(start, end);
|
|
1202
|
+
if (!range)
|
|
1203
|
+
continue;
|
|
1204
|
+
// Half-open overlap: [a1, a2) ∩ [b1, b2) ≠ ∅ iff a1 < b2 ∧ b1 < a2.
|
|
1205
|
+
if (pendingRange[0] < range[1] && range[0] < pendingRange[1])
|
|
1206
|
+
return component;
|
|
1207
|
+
}
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
export function componentTitleFor(component, resolvedEntityName) {
|
|
1211
|
+
const metadata = component.metadata;
|
|
1212
|
+
const entityName = cleanDisplayLabel(resolvedEntityName);
|
|
1213
|
+
if (entityName)
|
|
1214
|
+
return entityName;
|
|
1215
|
+
const catalogName = cleanDisplayLabel(metadata?.catalogItem?.name);
|
|
1216
|
+
if (catalogName)
|
|
1217
|
+
return catalogName;
|
|
1218
|
+
if (component.kind === "flight_placeholder" || component.kind === "flight_order") {
|
|
1219
|
+
const origin = cleanDisplayLabel(metadata?.flightDraft?.origin);
|
|
1220
|
+
const destination = cleanDisplayLabel(metadata?.flightDraft?.destination);
|
|
1221
|
+
if (origin && destination)
|
|
1222
|
+
return `${origin} → ${destination}`;
|
|
1223
|
+
}
|
|
1224
|
+
if (component.entityModule === "cruises") {
|
|
1225
|
+
const cabin = cleanDisplayLabel(metadata?.cruiseDraft?.cabin);
|
|
1226
|
+
if (cabin)
|
|
1227
|
+
return `Cabin ${cabin}`;
|
|
1228
|
+
}
|
|
1229
|
+
if (component.entityModule === "accommodations") {
|
|
1230
|
+
const accommodationName = cleanDisplayLabel(metadata?.accommodation?.propertyName) ??
|
|
1231
|
+
cleanDisplayLabel(metadata?.accommodation?.name) ??
|
|
1232
|
+
cleanDisplayLabel(metadata?.accommodation?.roomTypeName);
|
|
1233
|
+
if (accommodationName)
|
|
1234
|
+
return accommodationName;
|
|
1235
|
+
}
|
|
1236
|
+
if (metadata?.cruiseDraft) {
|
|
1237
|
+
const cabin = cleanDisplayLabel(metadata.cruiseDraft.cabin);
|
|
1238
|
+
if (cabin)
|
|
1239
|
+
return `Cabin ${cabin}`;
|
|
1240
|
+
}
|
|
1241
|
+
if (component.kind === "manual_placeholder") {
|
|
1242
|
+
const serviceName = cleanDisplayLabel(metadata?.manualService?.name);
|
|
1243
|
+
if (serviceName)
|
|
1244
|
+
return serviceName;
|
|
1245
|
+
const title = cleanDisplayLabel(component.title);
|
|
1246
|
+
if (title)
|
|
1247
|
+
return title;
|
|
1248
|
+
const description = cleanDisplayLabel(component.description);
|
|
1249
|
+
if (description)
|
|
1250
|
+
return description;
|
|
1251
|
+
}
|
|
1252
|
+
return componentReferenceLabelFor(component);
|
|
1253
|
+
}
|
|
1254
|
+
export function componentOptionSummaryFor(component) {
|
|
1255
|
+
const metadata = component.metadata;
|
|
1256
|
+
if (metadata?.flightDraft) {
|
|
1257
|
+
const flightLabels = flightSelectionLabels(metadata.flightDraft.selectedOffer ?? null, metadata.flightDraft.ancillaries ?? null);
|
|
1258
|
+
if (flightLabels.length > 0)
|
|
1259
|
+
return flightLabels.join(", ");
|
|
1260
|
+
}
|
|
1261
|
+
const selections = metadata?.bookingDraftV1?.configure?.optionSelections ?? [];
|
|
1262
|
+
const labels = selections.flatMap((selection) => {
|
|
1263
|
+
const quantity = typeof selection.quantity === "number" && Number.isFinite(selection.quantity)
|
|
1264
|
+
? selection.quantity
|
|
1265
|
+
: 0;
|
|
1266
|
+
if (quantity <= 0)
|
|
1267
|
+
return [];
|
|
1268
|
+
const name = cleanDisplayLabel(selection.optionName) ??
|
|
1269
|
+
cleanDisplayLabel(selection.optionUnitName) ??
|
|
1270
|
+
cleanDisplayLabel(selection.optionId) ??
|
|
1271
|
+
cleanDisplayLabel(selection.optionUnitId);
|
|
1272
|
+
if (!name)
|
|
1273
|
+
return [];
|
|
1274
|
+
return [`${quantity} × ${name}`];
|
|
1275
|
+
});
|
|
1276
|
+
return labels.length > 0 ? labels.join(", ") : null;
|
|
1277
|
+
}
|
|
1278
|
+
export function componentThumbnailFor(component) {
|
|
1279
|
+
const metadata = component.metadata;
|
|
1280
|
+
return cleanDisplayLabel(metadata?.catalogItem?.thumbnailUrl);
|
|
1281
|
+
}
|
|
1282
|
+
export function componentReferenceLabelFor(component) {
|
|
1283
|
+
const reference = component.providerRef ??
|
|
1284
|
+
component.supplierRef ??
|
|
1285
|
+
component.bookingId ??
|
|
1286
|
+
component.orderId ??
|
|
1287
|
+
component.paymentSessionId ??
|
|
1288
|
+
component.sourceRef ??
|
|
1289
|
+
component.entityId ??
|
|
1290
|
+
component.id;
|
|
1291
|
+
return reference.length > 18 ? reference.slice(0, 18) : reference;
|
|
1292
|
+
}
|
|
1293
|
+
function cleanDisplayLabel(value) {
|
|
1294
|
+
const trimmed = value?.trim();
|
|
1295
|
+
if (!trimmed)
|
|
1296
|
+
return null;
|
|
1297
|
+
const normalized = trimmed.toLowerCase();
|
|
1298
|
+
if (normalized === "untitled trip" ||
|
|
1299
|
+
normalized === "untitled component" ||
|
|
1300
|
+
normalized === "flight placeholder" ||
|
|
1301
|
+
normalized.startsWith("flight placeholder ") ||
|
|
1302
|
+
normalized === "manual placeholder" ||
|
|
1303
|
+
normalized === "cruise" ||
|
|
1304
|
+
normalized === "cruise placeholder" ||
|
|
1305
|
+
normalized === "catalog booking" ||
|
|
1306
|
+
normalized === "product booking" ||
|
|
1307
|
+
normalized === "stay booking" ||
|
|
1308
|
+
normalized === "cruise booking" ||
|
|
1309
|
+
normalized === "external order" ||
|
|
1310
|
+
normalized === "flight order" ||
|
|
1311
|
+
/^component \d+$/.test(normalized)) {
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
return trimmed;
|
|
1315
|
+
}
|
|
1316
|
+
function recordFromUnknown(value) {
|
|
1317
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
1318
|
+
? value
|
|
1319
|
+
: null;
|
|
1320
|
+
}
|
|
1321
|
+
function paymentScheduleValueFromUnknown(value) {
|
|
1322
|
+
const record = recordFromUnknown(value);
|
|
1323
|
+
const mode = record?.mode === "split" ? "split" : record?.mode === "full" ? "full" : null;
|
|
1324
|
+
if (!mode)
|
|
1325
|
+
return { ...emptyPaymentScheduleValue };
|
|
1326
|
+
return {
|
|
1327
|
+
...emptyPaymentScheduleValue,
|
|
1328
|
+
...record,
|
|
1329
|
+
mode,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
// Backend-emitted noise codes that just acknowledge how the price was set —
|
|
1333
|
+
// staff-built trips always carry `manual_placeholder_price` for manual /
|
|
1334
|
+
// external / flight placeholders. `currency_mismatch:*` is also noise here
|
|
1335
|
+
// because the rail already breaks totals out per currency.
|
|
1336
|
+
function isUserVisibleWarning(code) {
|
|
1337
|
+
if (code === "manual_placeholder_price")
|
|
1338
|
+
return false;
|
|
1339
|
+
if (code.startsWith("currency_mismatch"))
|
|
1340
|
+
return false;
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
export function formatDateTime(iso) {
|
|
1344
|
+
const parsed = new Date(iso);
|
|
1345
|
+
if (Number.isNaN(parsed.getTime()))
|
|
1346
|
+
return iso;
|
|
1347
|
+
const hasTime = iso.includes("T") || iso.includes(" ");
|
|
1348
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
1349
|
+
year: "numeric",
|
|
1350
|
+
month: "short",
|
|
1351
|
+
day: "numeric",
|
|
1352
|
+
...(hasTime ? { hour: "2-digit", minute: "2-digit" } : {}),
|
|
1353
|
+
}).format(parsed);
|
|
1354
|
+
}
|
|
1355
|
+
function formatDepartureLabel(slot) {
|
|
1356
|
+
const date = formatDateTime(slot.startsAt);
|
|
1357
|
+
const duration = slot.nights ? ` · ${slot.nights}n` : slot.days ? ` · ${slot.days}d` : "";
|
|
1358
|
+
const capacity = slot.unlimited
|
|
1359
|
+
? ""
|
|
1360
|
+
: slot.remainingPax != null
|
|
1361
|
+
? ` · ${slot.remainingPax} left`
|
|
1362
|
+
: "";
|
|
1363
|
+
return `${date}${duration}${capacity}`;
|
|
1364
|
+
}
|
|
1365
|
+
export function formatMoney(amountCents, currencyCode) {
|
|
1366
|
+
if (amountCents == null)
|
|
1367
|
+
return "-";
|
|
1368
|
+
return (amountCents / 100).toLocaleString(undefined, {
|
|
1369
|
+
style: "currency",
|
|
1370
|
+
currency: currencyCode ?? "EUR",
|
|
1371
|
+
});
|
|
1372
|
+
}
|