@voyantjs/bookings-ui 0.107.0 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/option-units-stepper-section.d.ts +9 -1
- package/dist/components/option-units-stepper-section.d.ts.map +1 -1
- package/dist/components/option-units-stepper-section.js +10 -2
- package/dist/components/person-picker-section.d.ts +7 -1
- package/dist/components/person-picker-section.d.ts.map +1 -1
- package/dist/components/person-picker-section.js +2 -2
- package/dist/i18n/en.d.ts +37 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +40 -4
- package/dist/i18n/messages.d.ts +37 -1
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/i18n/provider.d.ts +74 -2
- package/dist/i18n/provider.d.ts.map +1 -1
- package/dist/i18n/ro.d.ts +37 -1
- package/dist/i18n/ro.d.ts.map +1 -1
- package/dist/i18n/ro.js +39 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/journey/components/booking-journey.d.ts.map +1 -1
- package/dist/journey/components/booking-journey.js +270 -27
- package/dist/journey/components/journey-steps/accommodation-step.d.ts +3 -0
- package/dist/journey/components/journey-steps/accommodation-step.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/accommodation-step.js +71 -0
- package/dist/journey/components/journey-steps/addons-step.d.ts +3 -0
- package/dist/journey/components/journey-steps/addons-step.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/addons-step.js +40 -0
- package/dist/journey/components/journey-steps/billing-step.d.ts +8 -0
- package/dist/journey/components/journey-steps/billing-step.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/billing-step.js +78 -0
- package/dist/journey/components/journey-steps/configure-steps.d.ts +28 -0
- package/dist/journey/components/journey-steps/configure-steps.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/configure-steps.js +231 -0
- package/dist/journey/components/journey-steps/documents-step.d.ts +11 -0
- package/dist/journey/components/journey-steps/documents-step.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/documents-step.js +36 -0
- package/dist/journey/components/journey-steps/payment-step.d.ts +29 -0
- package/dist/journey/components/journey-steps/payment-step.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/payment-step.js +224 -0
- package/dist/journey/components/journey-steps/review-step.d.ts +27 -0
- package/dist/journey/components/journey-steps/review-step.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/review-step.js +18 -0
- package/dist/journey/components/journey-steps/shared.d.ts +75 -0
- package/dist/journey/components/journey-steps/shared.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/shared.js +108 -0
- package/dist/journey/components/journey-steps/travelers-step.d.ts +7 -0
- package/dist/journey/components/journey-steps/travelers-step.d.ts.map +1 -0
- package/dist/journey/components/journey-steps/travelers-step.js +201 -0
- package/dist/journey/components/journey-steps.d.ts +13 -39
- package/dist/journey/components/journey-steps.d.ts.map +1 -1
- package/dist/journey/components/journey-steps.js +16 -613
- package/dist/journey/components/side-panel.d.ts +7 -2
- package/dist/journey/components/side-panel.d.ts.map +1 -1
- package/dist/journey/components/side-panel.js +73 -24
- package/dist/journey/index.d.ts +2 -2
- package/dist/journey/index.d.ts.map +1 -1
- package/dist/journey/index.js +1 -1
- package/dist/journey/lib/pax-band-dependencies.d.ts +27 -0
- package/dist/journey/lib/pax-band-dependencies.d.ts.map +1 -0
- package/dist/journey/lib/pax-band-dependencies.js +50 -0
- package/dist/journey/lib/payment-schedule.d.ts +19 -0
- package/dist/journey/lib/payment-schedule.d.ts.map +1 -0
- package/dist/journey/lib/payment-schedule.js +90 -0
- package/dist/journey/types.d.ts +141 -8
- package/dist/journey/types.d.ts.map +1 -1
- package/dist/journey/types.js +3 -1
- package/package.json +32 -32
|
@@ -1,617 +1,20 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { Button } from "@voyantjs/ui/components/button";
|
|
4
|
-
import { Card, CardContent, CardHeader, CardTitle } from "@voyantjs/ui/components/card";
|
|
5
|
-
import { CountryCombobox } from "@voyantjs/ui/components/country-combobox";
|
|
6
|
-
import { DatePicker } from "@voyantjs/ui/components/date-picker";
|
|
7
|
-
import { Input } from "@voyantjs/ui/components/input";
|
|
8
|
-
import { Label } from "@voyantjs/ui/components/label";
|
|
9
|
-
import { PhoneInput } from "@voyantjs/ui/components/phone-input";
|
|
10
|
-
import { RadioGroup, RadioGroupItem } from "@voyantjs/ui/components/radio-group";
|
|
11
|
-
import { Textarea } from "@voyantjs/ui/components/textarea";
|
|
12
|
-
import { Loader2 } from "lucide-react";
|
|
13
|
-
import { formatMessage, useBookingsUiMessagesOrDefault } from "../../i18n/index.js";
|
|
14
|
-
import { canCopyBillingContactToTraveler, patchBilling, patchConfigure, patchPaxCount, setAccommodation, setAddons, setPayment, setTravelers, totalPax, } from "../lib/draft-state.js";
|
|
15
|
-
// ─────────────────────────────────────────────────────────────────
|
|
16
|
-
// Configure
|
|
17
|
-
// ─────────────────────────────────────────────────────────────────
|
|
18
|
-
export function ConfigureStep({ draft, setDraft, shape, }) {
|
|
19
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
20
|
-
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: messages.bookingJourney.steps.configure }) }), _jsxs(CardContent, { className: "space-y-6", children: [_jsx(PaxBands, { draft: draft, setDraft: setDraft, shape: shape }), _jsx(DepartureFields, { draft: draft, setDraft: setDraft, shape: shape })] })] }));
|
|
21
|
-
}
|
|
22
|
-
function PaxBands({ draft, setDraft, shape }) {
|
|
23
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
24
|
-
return (_jsxs("div", { className: "space-y-3", children: [_jsx(Label, { children: messages.bookingJourney.configure.travelers }), _jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: shape.paxBands.map((band) => {
|
|
25
|
-
const value = draft.configure.pax?.[band.code] ?? 0;
|
|
26
|
-
return (_jsxs("div", { className: "flex items-center justify-between rounded border p-3", children: [_jsxs("div", { children: [_jsx("div", { className: "font-medium", children: band.label }), band.minAge != null || band.maxAge != null ? (_jsx("div", { className: "text-muted-foreground text-xs", children: ageHint(band.minAge, band.maxAge, messages) })) : null] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "sm", type: "button", disabled: value <= band.minCount, onClick: () => setDraft(patchPaxCount(draft, band.code, value - 1)), children: "\u2212" }), _jsx("span", { className: "min-w-6 text-center", children: value }), _jsx(Button, { variant: "outline", size: "sm", type: "button", disabled: value >= band.maxCount, onClick: () => setDraft(patchPaxCount(draft, band.code, value + 1)), children: "+" })] })] }, band.code));
|
|
27
|
-
}) }), _jsx(PaxValidation, { draft: draft, shape: shape })] }));
|
|
28
|
-
}
|
|
29
|
-
function PaxValidation({ draft, shape, }) {
|
|
30
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
31
|
-
const total = totalPax(draft);
|
|
32
|
-
const { min, max } = shape.paxBandsAllowedTotal;
|
|
33
|
-
if (total < min) {
|
|
34
|
-
return (_jsx("p", { className: "text-sm text-amber-600", children: formatMessage(messages.bookingJourney.validation.addAtLeastTravelers, {
|
|
35
|
-
count: min,
|
|
36
|
-
plural: min === 1 ? "" : "s",
|
|
37
|
-
}) }));
|
|
38
|
-
}
|
|
39
|
-
if (total > max) {
|
|
40
|
-
return (_jsx("p", { className: "text-sm text-destructive", children: formatMessage(messages.bookingJourney.validation.maxTravelersPerBooking, {
|
|
41
|
-
count: max,
|
|
42
|
-
}) }));
|
|
43
|
-
}
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
function DepartureFields({ draft, setDraft, shape }) {
|
|
47
|
-
const subSteps = shape.configureSubSteps ?? [];
|
|
48
|
-
// Render every sub-step kind the descriptor declares. Cruise
|
|
49
|
-
// (cabin-category, cabin-number) lands here in Phase F.
|
|
50
|
-
if (subSteps.length === 0) {
|
|
51
|
-
return _jsx(DepartureBasic, { draft: draft, setDraft: setDraft });
|
|
52
|
-
}
|
|
53
|
-
return (_jsx("div", { className: "space-y-4", children: subSteps.map((sub) => {
|
|
54
|
-
// Sub-step kinds are unique per descriptor — kind serves as
|
|
55
|
-
// a stable key.
|
|
56
|
-
if (sub.kind === "departure") {
|
|
57
|
-
return _jsx(DepartureBasic, { draft: draft, setDraft: setDraft }, "departure");
|
|
58
|
-
}
|
|
59
|
-
if (sub.kind === "product-option") {
|
|
60
|
-
return (_jsx(ProductOptionFields, { draft: draft, setDraft: setDraft, options: sub.options }, "product-option"));
|
|
61
|
-
}
|
|
62
|
-
if (sub.kind === "date-range") {
|
|
63
|
-
return (_jsx(DateRangeFields, { draft: draft, setDraft: setDraft, minNights: sub.minNights, maxNights: sub.maxNights }, "date-range"));
|
|
64
|
-
}
|
|
65
|
-
if (sub.kind === "cabin-category") {
|
|
66
|
-
return (_jsx(CabinCategoryFields, { draft: draft, setDraft: setDraft, categories: sub.categories }, "cabin-category"));
|
|
67
|
-
}
|
|
68
|
-
if (sub.kind === "cabin-number") {
|
|
69
|
-
return (_jsx(CabinNumberFields, { draft: draft, setDraft: setDraft, perCategory: sub.perCategory }, "cabin-number"));
|
|
70
|
-
}
|
|
71
|
-
if (sub.kind === "air-arrangement") {
|
|
72
|
-
return _jsx(AirArrangementFields, { draft: draft, setDraft: setDraft }, "air-arrangement");
|
|
73
|
-
}
|
|
74
|
-
// "occupancy" — already rendered as PaxBands above; no sub-row.
|
|
75
|
-
return null;
|
|
76
|
-
}) }));
|
|
77
|
-
}
|
|
78
|
-
function ProductOptionFields({ draft, setDraft, options, }) {
|
|
79
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
80
|
-
const selectedId = draft.configure.variantId;
|
|
81
|
-
if (options.length === 0)
|
|
82
|
-
return null;
|
|
83
|
-
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: messages.bookingJourney.configure.option }), _jsx("div", { className: "grid grid-cols-1 gap-2", children: options.map((option) => {
|
|
84
|
-
const selected = option.id === selectedId;
|
|
85
|
-
return (_jsxs("button", { type: "button", onClick: () => setDraft(patchConfigure(draft, { variantId: option.id })), className: `w-full rounded border p-3 text-left text-sm ${selected ? "border-primary ring-2 ring-primary" : ""}`, children: [_jsx("span", { className: "font-medium", children: option.name }), option.code ? (_jsx("span", { className: "ml-2 text-muted-foreground text-xs uppercase", children: option.code })) : null, option.description ? (_jsx("span", { className: "mt-1 block text-muted-foreground text-xs", children: option.description })) : null] }, option.id));
|
|
86
|
-
}) })] }));
|
|
87
|
-
}
|
|
88
|
-
function DepartureBasic({ draft, setDraft, }) {
|
|
89
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
90
|
-
return (_jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(DateField, { id: "bj-departure-date", label: messages.bookingJourney.configure.departureDate, value: draft.configure.departureDate ?? "", onChange: (v) => setDraft(patchConfigure(draft, { departureDate: v })), range: "future" }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "bj-departure-time", children: messages.bookingJourney.configure.timeOptional }), _jsx(Input, { id: "bj-departure-time", type: "time", value: draft.configure.departureTime ?? "", onChange: (e) => setDraft(patchConfigure(draft, { departureTime: e.target.value })) })] })] }));
|
|
91
|
-
}
|
|
92
|
-
function DateRangeFields({ draft, setDraft, minNights, maxNights, }) {
|
|
93
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
94
|
-
const range = draft.configure.dateRange;
|
|
95
|
-
return (_jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(DateField, { id: "bj-checkin", label: messages.bookingJourney.configure.checkIn, value: range?.checkIn ?? "", onChange: (v) => setDraft(patchConfigure(draft, {
|
|
96
|
-
dateRange: { checkIn: v, checkOut: range?.checkOut ?? "" },
|
|
97
|
-
})), range: "future" }), _jsx(DateField, { id: "bj-checkout", label: formatMessage(messages.bookingJourney.configure.checkOutWithNights, {
|
|
98
|
-
minNights,
|
|
99
|
-
maxNights,
|
|
100
|
-
}), value: range?.checkOut ?? "", onChange: (v) => setDraft(patchConfigure(draft, {
|
|
101
|
-
dateRange: { checkIn: range?.checkIn ?? "", checkOut: v },
|
|
102
|
-
})), range: "future" })] }));
|
|
103
|
-
}
|
|
104
|
-
function CabinCategoryFields({ draft, setDraft, categories, }) {
|
|
105
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
106
|
-
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: messages.bookingJourney.configure.cabinCategory }), _jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: categories.map((cat) => {
|
|
107
|
-
const selected = draft.configure.cabinCategoryId === cat.id;
|
|
108
|
-
return (_jsxs("button", { type: "button", className: `rounded border p-3 text-left ${selected ? "border-primary ring-2 ring-primary" : ""}`, onClick: () => setDraft(patchConfigure(draft, { cabinCategoryId: cat.id, cabinNumberId: undefined })), children: [_jsx("div", { className: "font-medium", children: cat.name }), cat.description ? (_jsx("div", { className: "text-muted-foreground text-xs", children: cat.description })) : null] }, cat.id));
|
|
109
|
-
}) })] }));
|
|
110
|
-
}
|
|
111
|
-
function AirArrangementFields({ draft, setDraft, }) {
|
|
112
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
113
|
-
const current = draft.configure.airArrangement;
|
|
114
|
-
const options = [
|
|
115
|
-
{
|
|
116
|
-
value: "cruise_line",
|
|
117
|
-
label: messages.bookingJourney.configure.airOptions.cruise_line.label,
|
|
118
|
-
description: messages.bookingJourney.configure.airOptions.cruise_line.description,
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
value: "independent",
|
|
122
|
-
label: messages.bookingJourney.configure.airOptions.independent.label,
|
|
123
|
-
description: messages.bookingJourney.configure.airOptions.independent.description,
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
value: "none",
|
|
127
|
-
label: messages.bookingJourney.configure.airOptions.none.label,
|
|
128
|
-
description: messages.bookingJourney.configure.airOptions.none.description,
|
|
129
|
-
},
|
|
130
|
-
];
|
|
131
|
-
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: messages.bookingJourney.configure.airArrangements }), _jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-3", children: options.map((opt) => {
|
|
132
|
-
const selected = current === opt.value;
|
|
133
|
-
return (_jsxs("button", { type: "button", className: `rounded border p-3 text-left text-sm ${selected ? "border-primary ring-2 ring-primary" : ""}`, onClick: () => setDraft(patchConfigure(draft, { airArrangement: opt.value })), children: [_jsx("div", { className: "font-medium", children: opt.label }), _jsx("div", { className: "text-muted-foreground text-xs", children: opt.description })] }, opt.value));
|
|
134
|
-
}) })] }));
|
|
135
|
-
}
|
|
136
|
-
function CabinNumberFields({ draft, setDraft, perCategory, }) {
|
|
137
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
138
|
-
const catId = draft.configure.cabinCategoryId;
|
|
139
|
-
if (!catId)
|
|
140
|
-
return null;
|
|
141
|
-
const cabins = perCategory[catId] ?? [];
|
|
142
|
-
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: messages.bookingJourney.configure.cabinNumber }), _jsx("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-4", children: cabins.map((cabin) => {
|
|
143
|
-
const selected = draft.configure.cabinNumberId === cabin.id;
|
|
144
|
-
return (_jsx("button", { type: "button", className: `rounded border p-2 text-sm ${selected ? "border-primary ring-2 ring-primary" : ""}`, onClick: () => setDraft(patchConfigure(draft, { cabinNumberId: cabin.id })), children: cabin.label }, cabin.id));
|
|
145
|
-
}) })] }));
|
|
146
|
-
}
|
|
147
|
-
// ─────────────────────────────────────────────────────────────────
|
|
148
|
-
// Billing
|
|
149
|
-
// ─────────────────────────────────────────────────────────────────
|
|
150
|
-
export function BillingStep({ draft, setDraft, renderLeadContactPicker, renderExtras, }) {
|
|
151
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
152
|
-
const billing = draft.billing;
|
|
153
|
-
const apply = (contact) => {
|
|
154
|
-
setDraft(patchBilling(draft, {
|
|
155
|
-
contact: {
|
|
156
|
-
firstName: contact.firstName,
|
|
157
|
-
lastName: contact.lastName,
|
|
158
|
-
email: contact.email ?? "",
|
|
159
|
-
phone: contact.phone,
|
|
160
|
-
},
|
|
161
|
-
}));
|
|
162
|
-
};
|
|
163
|
-
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: messages.bookingJourney.billing.title }) }), _jsxs(CardContent, { className: "space-y-6", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: messages.bookingJourney.billing.buyerType }), _jsxs(RadioGroup, { value: billing.buyerType, onValueChange: (v) => setDraft(patchBilling(draft, { buyerType: v })), className: "flex gap-4", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx(RadioGroupItem, { value: "B2C" }), " ", messages.bookingJourney.billing.individual] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx(RadioGroupItem, { value: "B2B" }), " ", messages.bookingJourney.billing.company] })] })] }), renderLeadContactPicker ? _jsx("div", { children: renderLeadContactPicker({ apply }) }) : null, _jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(Field, { id: "bj-billing-firstName", label: messages.bookingJourney.billing.firstName, value: billing.contact.firstName, onChange: (v) => setDraft(patchBilling(draft, { contact: { ...billing.contact, firstName: v } })) }), _jsx(Field, { id: "bj-billing-lastName", label: messages.bookingJourney.billing.lastName, value: billing.contact.lastName, onChange: (v) => setDraft(patchBilling(draft, { contact: { ...billing.contact, lastName: v } })) }), _jsx(Field, { id: "bj-billing-email", label: messages.bookingJourney.billing.email, type: "email", value: billing.contact.email, onChange: (v) => setDraft(patchBilling(draft, { contact: { ...billing.contact, email: v } })) }), _jsx(PhoneField, { id: "bj-billing-phone", label: messages.bookingJourney.billing.phone, value: billing.contact.phone ?? "", onChange: (v) => setDraft(patchBilling(draft, { contact: { ...billing.contact, phone: v } })) })] }), _jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(Field, { id: "bj-billing-line1", label: messages.bookingJourney.billing.addressLine1, value: billing.address.line1 ?? "", onChange: (v) => setDraft(patchBilling(draft, { address: { ...billing.address, line1: v } })) }), _jsx(Field, { id: "bj-billing-line2", label: messages.bookingJourney.billing.addressLine2Optional, value: billing.address.line2 ?? "", onChange: (v) => setDraft(patchBilling(draft, { address: { ...billing.address, line2: v } })) }), _jsx(Field, { id: "bj-billing-city", label: messages.bookingJourney.billing.city, value: billing.address.city ?? "", onChange: (v) => setDraft(patchBilling(draft, { address: { ...billing.address, city: v } })) }), _jsx(Field, { id: "bj-billing-postal", label: messages.bookingJourney.billing.postalCode, value: billing.address.postal ?? "", onChange: (v) => setDraft(patchBilling(draft, { address: { ...billing.address, postal: v } })) }), _jsxs("div", { className: "space-y-1 sm:col-span-2", children: [_jsx(Label, { htmlFor: "bj-billing-country", children: messages.bookingJourney.billing.country }), _jsx(CountryCombobox, { value: billing.address.country ?? null, onChange: (code) => setDraft(patchBilling(draft, {
|
|
164
|
-
address: { ...billing.address, country: code ?? "" },
|
|
165
|
-
})) })] })] }), billing.buyerType === "B2B" ? (_jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(Field, { id: "bj-billing-companyName", label: messages.bookingJourney.billing.companyName, value: billing.company?.name ?? "", onChange: (v) => setDraft(patchBilling(draft, {
|
|
166
|
-
company: { ...(billing.company ?? { name: "" }), name: v },
|
|
167
|
-
})) }), _jsx(Field, { id: "bj-billing-vatId", label: messages.bookingJourney.billing.vatId, value: billing.company?.vatId ?? "", onChange: (v) => setDraft(patchBilling(draft, {
|
|
168
|
-
company: { ...(billing.company ?? { name: "" }), vatId: v },
|
|
169
|
-
})) })] })) : null, renderExtras ? _jsx("div", { children: renderExtras() }) : null] })] }));
|
|
170
|
-
}
|
|
171
|
-
// ─────────────────────────────────────────────────────────────────
|
|
172
|
-
// Travelers
|
|
173
|
-
// ─────────────────────────────────────────────────────────────────
|
|
174
|
-
export function TravelersStep({ draft, setDraft, shape, renderTravelerContactPicker, }) {
|
|
175
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
176
|
-
const total = totalPax(draft);
|
|
177
|
-
// Auto-resize the travelers list to match pax counts. Newly-added
|
|
178
|
-
// rows pick a band based on the lowest-count band that's not yet
|
|
179
|
-
// saturated — naive but predictable.
|
|
180
|
-
const ensured = ensureTravelerRows(draft, total, shape);
|
|
181
|
-
if (ensured !== draft.travelers) {
|
|
182
|
-
setDraft(setTravelers(draft, ensured));
|
|
183
|
-
}
|
|
184
|
-
// Bands are bookkeeping for the pricing engine — the user only
|
|
185
|
-
// sees a flat list of travelers. Adding a traveler bumps the
|
|
186
|
-
// adult-band counter by default; the row's band is reassigned
|
|
187
|
-
// automatically once a DOB is entered (see TravelerCard's onDob).
|
|
188
|
-
const defaultAddBand = shape.paxBands.find((b) => b.code === "adult") ?? shape.paxBands[0];
|
|
189
|
-
const totalCap = shape.paxBandsAllowedTotal.max;
|
|
190
|
-
const canAddMore = ensured.length < totalCap && Boolean(defaultAddBand);
|
|
191
|
-
const removeTraveler = (rowIdx) => {
|
|
192
|
-
const band = ensured[rowIdx]?.band;
|
|
193
|
-
if (!band)
|
|
194
|
-
return;
|
|
195
|
-
const current = draft.configure.pax?.[band] ?? 0;
|
|
196
|
-
const spec = shape.paxBands.find((b) => b.code === band);
|
|
197
|
-
if (spec && current <= spec.minCount)
|
|
198
|
-
return;
|
|
199
|
-
setDraft(patchPaxCount(draft, band, current - 1));
|
|
200
|
-
};
|
|
201
|
-
const addTraveler = () => {
|
|
202
|
-
if (!defaultAddBand)
|
|
203
|
-
return;
|
|
204
|
-
const current = draft.configure.pax?.[defaultAddBand.code] ?? 0;
|
|
205
|
-
if (current >= defaultAddBand.maxCount)
|
|
206
|
-
return;
|
|
207
|
-
setDraft(patchPaxCount(draft, defaultAddBand.code, current + 1));
|
|
208
|
-
};
|
|
209
|
-
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: messages.bookingJourney.travelers.title }) }), _jsxs(CardContent, { className: "space-y-4", children: [ensured.length === 0 ? (_jsx("p", { className: "text-muted-foreground text-sm", children: messages.bookingJourney.travelers.empty })) : null, ensured.map((traveler, idx) => {
|
|
210
|
-
const apply = (contact) => {
|
|
211
|
-
const next = [...ensured];
|
|
212
|
-
next[idx] = {
|
|
213
|
-
...next[idx],
|
|
214
|
-
firstName: contact.firstName,
|
|
215
|
-
lastName: contact.lastName,
|
|
216
|
-
email: contact.email,
|
|
217
|
-
phone: contact.phone,
|
|
218
|
-
};
|
|
219
|
-
setDraft(setTravelers(draft, next));
|
|
220
|
-
};
|
|
221
|
-
const bandSpec = shape.paxBands.find((b) => b.code === traveler.band);
|
|
222
|
-
const currentBandCount = draft.configure.pax?.[traveler.band] ?? 0;
|
|
223
|
-
const canRemove = !bandSpec || currentBandCount > bandSpec.minCount;
|
|
224
|
-
return (_jsx(TravelerCard, { idx: idx, traveler: traveler, shape: shape, draft: draft, setDraft: setDraft, renderTravelerContactPicker: renderTravelerContactPicker, apply: apply, onRemove: canRemove ? () => removeTraveler(idx) : undefined }, traveler.rowId ?? idx));
|
|
225
|
-
}), canAddMore ? (_jsx("div", { className: "border-t pt-3", children: _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: addTraveler, children: messages.bookingJourney.travelers.addTraveler }) })) : null] })] }));
|
|
226
|
-
}
|
|
227
1
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* passport drop off the form for non-adult travelers.
|
|
2
|
+
* Step components rendered inside `<BookingJourney />`. Each takes a
|
|
3
|
+
* draft + setDraft pair plus the active descriptor; updates flow up
|
|
4
|
+
* via setDraft and the shell re-quotes on the next debounce tick.
|
|
232
5
|
*
|
|
233
|
-
*
|
|
234
|
-
* rendered inline as fixed widgets; the rest comes off
|
|
235
|
-
* `shape.travelerFields` so verticals can extend the set without
|
|
236
|
-
* touching the wizard.
|
|
237
|
-
*/
|
|
238
|
-
function TravelerCard({ idx, traveler, shape, draft, setDraft, renderTravelerContactPicker, apply, onRemove, }) {
|
|
239
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
240
|
-
// The band is bookkeeping — only show fields that apply to this
|
|
241
|
-
// band per the descriptor, but never expose the band tag in the UI.
|
|
242
|
-
// The user just sees a flat traveler list.
|
|
243
|
-
const applicableFields = shape.travelerFields.filter((f) => {
|
|
244
|
-
if (!f.appliesToBands || f.appliesToBands.length === 0)
|
|
245
|
-
return true;
|
|
246
|
-
return f.appliesToBands.includes(traveler.band);
|
|
247
|
-
});
|
|
248
|
-
const dobField = applicableFields.find((f) => f.key === "dateOfBirth");
|
|
249
|
-
const phoneField = applicableFields.find((f) => f.key === "phone");
|
|
250
|
-
const dynamicFields = applicableFields.filter((f) => !["firstName", "lastName", "email", "phone", "dateOfBirth"].includes(f.key));
|
|
251
|
-
// Live age from DOB — surfaces in the header so the user gets
|
|
252
|
-
// feedback as they pick a date.
|
|
253
|
-
const computedAge = traveler.dateOfBirth ? computeAge(traveler.dateOfBirth) : null;
|
|
254
|
-
// The DOB drives band assignment. When the user picks a date that
|
|
255
|
-
// would land in a different band, we silently reband the row and
|
|
256
|
-
// shift the pax counters so the engine quotes the right price.
|
|
257
|
-
// No "Move to X" prompts — the system just does the right thing.
|
|
258
|
-
const onDobChange = (v) => {
|
|
259
|
-
const age = v ? computeAge(v) : null;
|
|
260
|
-
let next = updateTravelerImmutable(draft, idx, { dateOfBirth: v });
|
|
261
|
-
if (age != null) {
|
|
262
|
-
const targetBand = shape.paxBands.find((b) => {
|
|
263
|
-
if (b.minAge != null && age < b.minAge)
|
|
264
|
-
return false;
|
|
265
|
-
if (b.maxAge != null && age > b.maxAge)
|
|
266
|
-
return false;
|
|
267
|
-
return true;
|
|
268
|
-
});
|
|
269
|
-
if (targetBand && targetBand.code !== traveler.band) {
|
|
270
|
-
next = rebandTraveler(next, idx, traveler.band, targetBand.code);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
setDraft(next);
|
|
274
|
-
};
|
|
275
|
-
// Out-of-range warning — only fires when the entered DOB doesn't
|
|
276
|
-
// fit ANY of the descriptor's bands (e.g. older than the supplier
|
|
277
|
-
// accepts). We can't auto-fix that one — the booking would be
|
|
278
|
-
// rejected and the user needs to know.
|
|
279
|
-
const ageOutOfBounds = computedAge != null &&
|
|
280
|
-
!shape.paxBands.some((b) => {
|
|
281
|
-
if (b.minAge != null && computedAge < b.minAge)
|
|
282
|
-
return false;
|
|
283
|
-
if (b.maxAge != null && computedAge > b.maxAge)
|
|
284
|
-
return false;
|
|
285
|
-
return true;
|
|
286
|
-
});
|
|
287
|
-
// Quick-fill from billing — useful when the lead booker is also a
|
|
288
|
-
// traveler (the common B2C case). Doesn't touch travel-document
|
|
289
|
-
// fields since those are personal to each traveler.
|
|
290
|
-
const billingContact = draft.billing.contact;
|
|
291
|
-
const canCopyFromBilling = canCopyBillingContactToTraveler(billingContact);
|
|
292
|
-
const copyFromBilling = () => {
|
|
293
|
-
updateTraveler(draft, setDraft, idx, {
|
|
294
|
-
firstName: billingContact.firstName,
|
|
295
|
-
lastName: billingContact.lastName,
|
|
296
|
-
email: billingContact.email || undefined,
|
|
297
|
-
phone: billingContact.phone || undefined,
|
|
298
|
-
});
|
|
299
|
-
};
|
|
300
|
-
return (_jsxs("div", { className: "rounded border p-4 space-y-3", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [_jsx("div", { className: "space-y-0.5", children: _jsxs("div", { className: "font-medium", children: [formatMessage(messages.bookingJourney.travelers.travelerNumber, {
|
|
301
|
-
number: idx + 1,
|
|
302
|
-
}), computedAge != null ? (_jsxs("span", { className: "text-muted-foreground font-normal", children: [" ", "\u00B7", " ", formatMessage(messages.bookingJourney.travelers.ageLabel, {
|
|
303
|
-
age: computedAge,
|
|
304
|
-
})] })) : null] }) }), _jsxs("div", { className: "flex items-center gap-2", children: [canCopyFromBilling ? (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: copyFromBilling, children: messages.bookingJourney.travelers.copyFromBilling })) : null, renderTravelerContactPicker
|
|
305
|
-
? renderTravelerContactPicker({ rowIndex: idx, apply })
|
|
306
|
-
: null, onRemove ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "text-destructive hover:text-destructive", onClick: onRemove, children: messages.bookingJourney.travelers.remove })) : null] })] }), _jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(Field, { id: `bj-trav-${idx}-first`, label: messages.bookingJourney.billing.firstName, value: traveler.firstName, onChange: (v) => updateTraveler(draft, setDraft, idx, { firstName: v }) }), _jsx(Field, { id: `bj-trav-${idx}-last`, label: messages.bookingJourney.billing.lastName, value: traveler.lastName, onChange: (v) => updateTraveler(draft, setDraft, idx, { lastName: v }) }), applicableFields.some((f) => f.key === "email") ? (_jsx(Field, { id: `bj-trav-${idx}-email`, label: messages.bookingJourney.billing.email, type: "email", value: traveler.email ?? "", onChange: (v) => updateTraveler(draft, setDraft, idx, { email: v }) })) : null, phoneField ? (_jsx(PhoneField, { id: `bj-trav-${idx}-phone`,
|
|
307
|
-
// i18n-literal-ok Required marker appended to a descriptor-supplied field label.
|
|
308
|
-
label: phoneField.label + (phoneField.required ? " *" : ""), value: traveler.phone ?? "", onChange: (v) => updateTraveler(draft, setDraft, idx, { phone: v }) })) : null, dobField ? (_jsxs("div", { className: "space-y-1", children: [_jsx(DateField, { id: `bj-trav-${idx}-dob`,
|
|
309
|
-
// i18n-literal-ok Required marker appended to a descriptor-supplied field label.
|
|
310
|
-
label: dobField.label + (dobField.required ? " *" : ""), value: traveler.dateOfBirth ?? "", onChange: onDobChange, range: "past" }), ageOutOfBounds ? (_jsxs("p", { className: "text-amber-600 text-xs dark:text-amber-400", children: ["\u26A0", " ", formatMessage(messages.bookingJourney.validation.ageOutOfRange, {
|
|
311
|
-
age: computedAge,
|
|
312
|
-
})] })) : null] })) : null, dynamicFields.map((field) => {
|
|
313
|
-
const value = traveler.documents?.[field.key] ?? "";
|
|
314
|
-
const onFieldChange = (v) => updateTraveler(draft, setDraft, idx, {
|
|
315
|
-
documents: { ...traveler.documents, [field.key]: v },
|
|
316
|
-
});
|
|
317
|
-
// i18n-literal-ok Required marker appended to a descriptor-supplied field label.
|
|
318
|
-
const labelText = field.label + (field.required ? " *" : "");
|
|
319
|
-
if (field.type === "select" && field.options) {
|
|
320
|
-
return (_jsx(SelectField, { id: `bj-trav-${idx}-${field.key}`, label: labelText, value: value, options: field.options, onChange: onFieldChange }, field.key));
|
|
321
|
-
}
|
|
322
|
-
if (field.type === "date") {
|
|
323
|
-
return (_jsx(DateField, { id: `bj-trav-${idx}-${field.key}`, label: labelText, value: value, onChange: onFieldChange, range: field.key === "documentExpiry" ? "document" : "future" }, field.key));
|
|
324
|
-
}
|
|
325
|
-
return (_jsx(Field, { id: `bj-trav-${idx}-${field.key}`, label: labelText, type: "text", value: value, onChange: onFieldChange }, field.key));
|
|
326
|
-
})] })] }));
|
|
327
|
-
}
|
|
328
|
-
function ensureTravelerRows(draft, total, shape) {
|
|
329
|
-
// Return the same reference when no resize is needed — the caller
|
|
330
|
-
// uses identity equality to decide whether to call setDraft, and a
|
|
331
|
-
// fresh array on every render would loop infinitely (set during
|
|
332
|
-
// render → re-render → set again).
|
|
333
|
-
if (draft.travelers.length === total)
|
|
334
|
-
return draft.travelers;
|
|
335
|
-
const list = [...draft.travelers];
|
|
336
|
-
while (list.length > total)
|
|
337
|
-
list.pop();
|
|
338
|
-
while (list.length < total) {
|
|
339
|
-
const idx = list.length;
|
|
340
|
-
const band = pickBandForIndex(draft, idx, shape);
|
|
341
|
-
list.push({
|
|
342
|
-
rowId: cryptoRowId(),
|
|
343
|
-
firstName: "",
|
|
344
|
-
lastName: "",
|
|
345
|
-
band,
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
return list;
|
|
349
|
-
}
|
|
350
|
-
function pickBandForIndex(draft, idx, shape) {
|
|
351
|
-
// Pick by remaining quota: distribute travelers in band order based
|
|
352
|
-
// on counts in `configure.pax`.
|
|
353
|
-
let cursor = 0;
|
|
354
|
-
for (const band of shape.paxBands) {
|
|
355
|
-
const count = draft.configure.pax?.[band.code] ?? 0;
|
|
356
|
-
if (idx < cursor + count) {
|
|
357
|
-
return band.code ?? "adult";
|
|
358
|
-
}
|
|
359
|
-
cursor += count;
|
|
360
|
-
}
|
|
361
|
-
return "adult";
|
|
362
|
-
}
|
|
363
|
-
function updateTraveler(draft, setDraft, idx, patch) {
|
|
364
|
-
setDraft(updateTravelerImmutable(draft, idx, patch));
|
|
365
|
-
}
|
|
366
|
-
/**
|
|
367
|
-
* Patch one traveler row and return a new draft. Useful when the
|
|
368
|
-
* caller wants to chain multiple draft updates (e.g. setting DOB +
|
|
369
|
-
* rebanding) into a single setDraft call.
|
|
370
|
-
*/
|
|
371
|
-
function updateTravelerImmutable(draft, idx, patch) {
|
|
372
|
-
const next = [...draft.travelers];
|
|
373
|
-
if (!next[idx])
|
|
374
|
-
return draft;
|
|
375
|
-
next[idx] = { ...next[idx], ...patch };
|
|
376
|
-
return setTravelers(draft, next);
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Move one traveler row from one band to another, keeping the pax
|
|
380
|
-
* counters consistent. Used by the auto-reband path on DOB change.
|
|
381
|
-
*/
|
|
382
|
-
function rebandTraveler(draft, idx, fromBand, toBand) {
|
|
383
|
-
const fromCount = draft.configure.pax?.[fromBand] ?? 0;
|
|
384
|
-
const toCount = draft.configure.pax?.[toBand] ?? 0;
|
|
385
|
-
let next = updateTravelerImmutable(draft, idx, { band: toBand });
|
|
386
|
-
next = patchPaxCount(next, fromBand, Math.max(0, fromCount - 1));
|
|
387
|
-
next = patchPaxCount(next, toBand, toCount + 1);
|
|
388
|
-
return next;
|
|
389
|
-
}
|
|
390
|
-
// ─────────────────────────────────────────────────────────────────
|
|
391
|
-
// Accommodation
|
|
392
|
-
// ─────────────────────────────────────────────────────────────────
|
|
393
|
-
export function AccommodationStep({ draft, setDraft, shape }) {
|
|
394
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
395
|
-
const subSteps = shape.accommodation?.subSteps ?? [];
|
|
396
|
-
const rooms = shape.accommodation?.roomOptions ?? [];
|
|
397
|
-
const accommodation = draft.accommodation ?? { rooms: [], travelerAssignments: {} };
|
|
398
|
-
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: messages.bookingJourney.accommodation.title }) }), _jsx(CardContent, { className: "space-y-4", children: rooms.length === 0 && subSteps.length === 0 ? (_jsx("p", { className: "text-muted-foreground text-sm", children: messages.bookingJourney.accommodation.empty })) : (_jsxs("div", { className: "space-y-3", children: [rooms.map((room) => {
|
|
399
|
-
const current = accommodation.rooms.find((r) => r.optionUnitId === room.id);
|
|
400
|
-
const ratePlans = room.ratePlans ?? [];
|
|
401
|
-
return (_jsxs("div", { className: "space-y-3 rounded border p-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("div", { className: "font-medium", children: room.name }), room.description ? (_jsx("div", { className: "text-muted-foreground text-xs", children: room.description })) : null] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "sm", type: "button", onClick: () => {
|
|
402
|
-
const list = accommodation.rooms.filter((r) => r.optionUnitId !== room.id);
|
|
403
|
-
const qty = (current?.quantity ?? 0) - 1;
|
|
404
|
-
if (qty > 0) {
|
|
405
|
-
list.push({
|
|
406
|
-
optionUnitId: room.id,
|
|
407
|
-
quantity: qty,
|
|
408
|
-
ratePlanId: current?.ratePlanId,
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
setDraft(setAccommodation(draft, { ...accommodation, rooms: list }));
|
|
412
|
-
}, children: "\u2212" }), _jsx("span", { className: "min-w-6 text-center", children: current?.quantity ?? 0 }), _jsx(Button, { variant: "outline", size: "sm", type: "button", onClick: () => {
|
|
413
|
-
const list = accommodation.rooms.filter((r) => r.optionUnitId !== room.id);
|
|
414
|
-
const qty = (current?.quantity ?? 0) + 1;
|
|
415
|
-
// Auto-select the only rate plan when there's
|
|
416
|
-
// exactly one — saves a click on the common case.
|
|
417
|
-
const ratePlanId = current?.ratePlanId ??
|
|
418
|
-
(ratePlans.length === 1 ? ratePlans[0]?.id : undefined);
|
|
419
|
-
list.push({ optionUnitId: room.id, quantity: qty, ratePlanId });
|
|
420
|
-
setDraft(setAccommodation(draft, { ...accommodation, rooms: list }));
|
|
421
|
-
}, children: "+" })] })] }), current && current.quantity > 0 && ratePlans.length > 0 ? (_jsx(RatePlanPicker, { roomId: room.id, ratePlans: ratePlans, selected: current.ratePlanId, onSelect: (planId) => {
|
|
422
|
-
const list = accommodation.rooms.map((r) => r.optionUnitId === room.id ? { ...r, ratePlanId: planId } : r);
|
|
423
|
-
setDraft(setAccommodation(draft, { ...accommodation, rooms: list }));
|
|
424
|
-
} })) : null] }, room.id));
|
|
425
|
-
}), subSteps.map((sub) => sub.kind === "extensions" ? (_jsx("div", { className: "rounded border p-3 text-muted-foreground text-sm", children: formatMessage(messages.bookingJourney.accommodation.extensionsAvailable, {
|
|
426
|
-
count: sub.options.length,
|
|
427
|
-
plural: sub.options.length === 1 ? "" : "s",
|
|
428
|
-
}) }, "extensions")) : null)] })) })] }));
|
|
429
|
-
}
|
|
430
|
-
function RatePlanPicker({ roomId, ratePlans, selected, onSelect, }) {
|
|
431
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
432
|
-
return (_jsxs("div", { className: "space-y-2 border-t pt-3", children: [_jsx(Label, { htmlFor: `bj-rate-plan-${roomId}`, children: messages.bookingJourney.accommodation.ratePlan }), _jsx("div", { className: "space-y-2", children: ratePlans.map((plan) => {
|
|
433
|
-
const isSelected = plan.id === selected;
|
|
434
|
-
return (_jsxs("button", { type: "button", onClick: () => onSelect(plan.id), className: `w-full rounded border p-2 text-left text-sm ${isSelected ? "border-primary ring-2 ring-primary" : ""}`, children: [_jsx("div", { className: "font-medium", children: plan.name }), plan.description ? (_jsx("div", { className: "text-muted-foreground text-xs", children: plan.description })) : null, plan.cancellationPolicy ? (_jsxs("div", { className: "text-muted-foreground text-xs", children: [messages.bookingJourney.accommodation.cancellationPrefix, " ", plan.cancellationPolicy] })) : null, plan.inclusions && plan.inclusions.length > 0 ? (_jsxs("div", { className: "text-muted-foreground text-xs", children: [messages.bookingJourney.accommodation.includesPrefix, " ", plan.inclusions.join(", ")] })) : null] }, plan.id));
|
|
435
|
-
}) })] }));
|
|
436
|
-
}
|
|
437
|
-
// ─────────────────────────────────────────────────────────────────
|
|
438
|
-
// Add-ons
|
|
439
|
-
// ─────────────────────────────────────────────────────────────────
|
|
440
|
-
export function AddonsStep({ draft, setDraft, shape }) {
|
|
441
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
442
|
-
const flat = shape.addons?.catalog ?? [];
|
|
443
|
-
const groups = shape.addons?.groups ?? [];
|
|
444
|
-
const all = [...flat, ...groups.flatMap((g) => g.items)];
|
|
445
|
-
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: messages.bookingJourney.addons.title }) }), _jsxs(CardContent, { className: "space-y-4", children: [all.length === 0 ? (_jsx("p", { className: "text-muted-foreground text-sm", children: messages.bookingJourney.addons.empty })) : null, groups.map((group) => {
|
|
446
|
-
// Group by port/day when the descriptor asks — cruise
|
|
447
|
-
// excursions arrive grouped by port name.
|
|
448
|
-
const buckets = group.groupBy === "port" || group.groupBy === "day"
|
|
449
|
-
? bucketBy(group.items, (i) => i.groupKey ?? messages.bookingJourney.addons.otherBucket)
|
|
450
|
-
: new Map([["", group.items]]);
|
|
451
|
-
return (_jsxs("div", { className: "space-y-3", children: [_jsx("div", { className: "font-medium text-sm", children: group.label }), [...buckets.entries()].map(([bucket, items]) => (_jsxs("div", { className: "space-y-2", children: [bucket ? (_jsx("div", { className: "text-muted-foreground text-xs uppercase", children: bucket })) : null, items.map((item) => (_jsx(AddonRow, { draft: draft, setDraft: setDraft, item: item }, item.id)))] }, bucket || "all")))] }, group.label));
|
|
452
|
-
}), flat.length > 0 && groups.length === 0 ? (_jsx("div", { className: "space-y-2", children: flat.map((item) => (_jsx(AddonRow, { draft: draft, setDraft: setDraft, item: item }, item.id))) })) : null] })] }));
|
|
453
|
-
}
|
|
454
|
-
function AddonRow({ draft, setDraft, item, }) {
|
|
455
|
-
const current = draft.addons.find((a) => a.extraId === item.id);
|
|
456
|
-
return (_jsxs("div", { className: "flex items-center justify-between rounded border p-3", children: [_jsxs("div", { children: [_jsx("div", { className: "font-medium", children: item.name }), item.description ? (_jsx("div", { className: "text-muted-foreground text-xs", children: item.description })) : null] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "sm", type: "button", onClick: () => {
|
|
457
|
-
const list = draft.addons.filter((a) => a.extraId !== item.id);
|
|
458
|
-
const qty = (current?.quantity ?? 0) - 1;
|
|
459
|
-
if (qty > 0)
|
|
460
|
-
list.push({ extraId: item.id, quantity: qty });
|
|
461
|
-
setDraft(setAddons(draft, list));
|
|
462
|
-
}, children: "\u2212" }), _jsx("span", { className: "min-w-6 text-center", children: current?.quantity ?? 0 }), _jsx(Button, { variant: "outline", size: "sm", type: "button", onClick: () => {
|
|
463
|
-
const list = draft.addons.filter((a) => a.extraId !== item.id);
|
|
464
|
-
const qty = (current?.quantity ?? 0) + 1;
|
|
465
|
-
list.push({ extraId: item.id, quantity: qty });
|
|
466
|
-
setDraft(setAddons(draft, list));
|
|
467
|
-
}, children: "+" })] })] }));
|
|
468
|
-
}
|
|
469
|
-
// ─────────────────────────────────────────────────────────────────
|
|
470
|
-
// Payment
|
|
471
|
-
// ─────────────────────────────────────────────────────────────────
|
|
472
|
-
export function PaymentStep({ draft, setDraft, shape, capabilities, renderProviderStep, }) {
|
|
473
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
474
|
-
// The descriptor lists what the *engine* supports; capabilities
|
|
475
|
-
// narrow further to what the *deployment* turned on. Both must
|
|
476
|
-
// accept an intent for the user to see it.
|
|
477
|
-
const allowed = shape.paymentIntents.filter((i) => isCapabilityEnabled(i, capabilities));
|
|
478
|
-
const intent = draft.payment.intent;
|
|
479
|
-
// Snap the draft's intent to the first allowed value when the
|
|
480
|
-
// current pick isn't on the list — covers descriptor changes
|
|
481
|
-
// mid-flow (e.g. owned→sourced switch narrows the list).
|
|
482
|
-
if (allowed.length > 0 && !allowed.includes(intent)) {
|
|
483
|
-
setDraft(setPayment(draft, { ...draft.payment, intent: allowed[0] }));
|
|
484
|
-
}
|
|
485
|
-
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: messages.bookingJourney.payment.title }) }), _jsxs(CardContent, { className: "space-y-4", children: [allowed.length === 0 ? (_jsx("p", { className: "text-muted-foreground text-sm", children: messages.bookingJourney.payment.empty })) : (_jsx(RadioGroup, { value: intent, onValueChange: (v) => setDraft(setPayment(draft, { ...draft.payment, intent: v })), className: "grid grid-cols-1 gap-2", children: allowed.map((i) => {
|
|
486
|
-
const meta = intentMeta(i, messages);
|
|
487
|
-
const selected = i === intent;
|
|
488
|
-
return (_jsxs("label", { className: "flex cursor-pointer items-start gap-3 rounded border p-3 text-sm transition-colors " +
|
|
489
|
-
(selected ? "border-primary bg-primary/5" : "border-input hover:bg-muted/50"), children: [_jsx(RadioGroupItem, { value: i, className: "mt-0.5" }), _jsxs("div", { className: "space-y-0.5", children: [_jsx("div", { className: "font-medium", children: meta.label }), _jsx("div", { className: "text-muted-foreground text-xs", children: meta.description })] })] }, i));
|
|
490
|
-
}) })), intent === "card" ? (renderProviderStep ? (_jsx("div", { children: renderProviderStep({
|
|
491
|
-
intent,
|
|
492
|
-
schedule: draft.payment.schedule,
|
|
493
|
-
capabilities,
|
|
494
|
-
}) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: messages.bookingJourney.payment.redirectedAfterConfirm }))) : null, intent === "bank_transfer" ? _jsx(BankTransferDetails, { capabilities: capabilities }) : null, intent === "inquiry" ? (_jsx("p", { className: "rounded border border-amber-300 bg-amber-50 p-3 text-amber-900 text-xs dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100", children: messages.bookingJourney.payment.inquiryNotice })) : null] })] }));
|
|
495
|
-
}
|
|
496
|
-
function BankTransferDetails({ capabilities, }) {
|
|
497
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
498
|
-
const note = capabilities.config?.bankTransferNote;
|
|
499
|
-
return (_jsxs("div", { className: "rounded border bg-muted/30 p-3 text-sm", children: [_jsx("p", { className: "font-medium", children: messages.bookingJourney.payment.bankTransferInstructions }), _jsx("p", { className: "text-muted-foreground text-xs", children: typeof note === "string" && note.length > 0
|
|
500
|
-
? note
|
|
501
|
-
: messages.bookingJourney.payment.bankTransferDefaultNote })] }));
|
|
502
|
-
}
|
|
503
|
-
function isCapabilityEnabled(intent, capabilities) {
|
|
504
|
-
switch (intent) {
|
|
505
|
-
case "card":
|
|
506
|
-
return capabilities.acceptsCard;
|
|
507
|
-
case "hold":
|
|
508
|
-
return capabilities.acceptsHold;
|
|
509
|
-
case "bank_transfer":
|
|
510
|
-
return capabilities.acceptsBankTransfer === true;
|
|
511
|
-
case "ticket_on_credit":
|
|
512
|
-
return capabilities.acceptsTicketOnCredit;
|
|
513
|
-
case "inquiry":
|
|
514
|
-
return capabilities.acceptsInquiry === true;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
function intentMeta(intent, messages) {
|
|
518
|
-
return {
|
|
519
|
-
label: messages.bookingJourney.payment.intentLabels[intent],
|
|
520
|
-
description: messages.bookingJourney.payment.intentDescriptions[intent],
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
// ─────────────────────────────────────────────────────────────────
|
|
524
|
-
// Review
|
|
525
|
-
// ─────────────────────────────────────────────────────────────────
|
|
526
|
-
export function ReviewStep({ draft, setDraft, isCommitting, onConfirm, renderExtras, surface, }) {
|
|
527
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
528
|
-
const isPublic = surface === "public";
|
|
529
|
-
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: messages.bookingJourney.review.title }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("div", { className: "font-medium", children: messages.bookingJourney.review.leadContact }), _jsxs("div", { className: "text-muted-foreground text-sm", children: [draft.billing.contact.firstName, " ", draft.billing.contact.lastName, " \u00B7", " ", draft.billing.contact.email] })] }), _jsxs("div", { children: [_jsx("div", { className: "font-medium", children: messages.bookingJourney.review.travelers }), _jsx("ul", { className: "text-muted-foreground text-sm", children: draft.travelers.map((t, i) => (_jsxs("li", { children: [t.firstName, " ", t.lastName, " (", t.band, ")"] }, t.rowId ?? i))) })] }), isPublic ? (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "bj-customer-notes", children: messages.bookingJourney.review.customerNotes }), _jsx(Textarea, { id: "bj-customer-notes", placeholder: messages.bookingJourney.review.customerNotesPlaceholder, value: draft.customerNotes ?? "", onChange: (e) => setDraft({ ...draft, customerNotes: e.target.value }) })] })) : (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "bj-internal-notes", children: messages.bookingJourney.review.internalNotes }), _jsx(Textarea, { id: "bj-internal-notes", value: draft.internalNotes ?? "", onChange: (e) => setDraft({ ...draft, internalNotes: e.target.value }) })] })), renderExtras ? _jsx("div", { children: renderExtras() }) : null, _jsx(Button, { onClick: onConfirm, disabled: isCommitting, children: isCommitting ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), messages.bookingJourney.review.confirming] })) : (messages.bookingJourney.review.confirmBooking) })] })] }));
|
|
530
|
-
}
|
|
531
|
-
// ─────────────────────────────────────────────────────────────────
|
|
532
|
-
// Helpers
|
|
533
|
-
// ─────────────────────────────────────────────────────────────────
|
|
534
|
-
function Field({ id, label, value, onChange, type, placeholder, }) {
|
|
535
|
-
return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: id, children: label }), _jsx(Input, { id: id, type: type ?? "text", value: value, placeholder: placeholder, onChange: (e) => onChange(e.target.value) })] }));
|
|
536
|
-
}
|
|
537
|
-
function PhoneField({ id, label, value, onChange, }) {
|
|
538
|
-
return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: id, children: label }), _jsx(PhoneInput, { id: id, defaultCountry: "GB", international: true, value: value || undefined, onChange: (v) => onChange(v ? String(v) : "") })] }));
|
|
539
|
-
}
|
|
540
|
-
/**
|
|
541
|
-
* Date field that uses the shared `<DatePicker />` from
|
|
542
|
-
* `@voyantjs/ui` with a month + year dropdown caption so users can
|
|
543
|
-
* jump across decades without arrow-clicking. The `range` hint picks
|
|
544
|
-
* a reasonable startMonth/endMonth window per use case:
|
|
6
|
+
* Per booking-journey-architecture §3.
|
|
545
7
|
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*/
|
|
550
|
-
function DateField({ id, label, value, onChange, range = "future", }) {
|
|
551
|
-
const today = new Date();
|
|
552
|
-
const todayMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
553
|
-
const startMonth = range === "past"
|
|
554
|
-
? new Date(today.getFullYear() - 120, 0, 1)
|
|
555
|
-
: range === "document"
|
|
556
|
-
? todayMonth
|
|
557
|
-
: todayMonth;
|
|
558
|
-
const endMonth = range === "past"
|
|
559
|
-
? new Date(today.getFullYear() + 1, 11, 1)
|
|
560
|
-
: range === "document"
|
|
561
|
-
? new Date(today.getFullYear() + 20, 11, 1)
|
|
562
|
-
: new Date(today.getFullYear() + 5, 11, 1);
|
|
563
|
-
const defaultMonth = range === "past" && !value ? new Date(today.getFullYear() - 30, 0, 1) : undefined;
|
|
564
|
-
return (_jsxs("div", { className: "space-y-1", id: id, children: [_jsx(Label, { children: label }), _jsx(DatePicker, { value: value || null, onChange: (v) => onChange(v ?? ""), captionLayout: "dropdown", startMonth: startMonth, endMonth: endMonth, defaultMonth: defaultMonth, displayFormat: "PPP" })] }));
|
|
565
|
-
}
|
|
566
|
-
function SelectField({ id, label, value, options, onChange, }) {
|
|
567
|
-
const messages = useBookingsUiMessagesOrDefault();
|
|
568
|
-
return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: id, children: label }), _jsxs("select", { id: id, className: "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", value: value, onChange: (e) => onChange(e.target.value), children: [_jsx("option", { value: "", children: messages.bookingJourney.values.selectPlaceholder }), options.map((opt) => (_jsx("option", { value: opt.value, children: opt.label }, opt.value)))] })] }));
|
|
569
|
-
}
|
|
570
|
-
/**
|
|
571
|
-
* Years between an ISO date-of-birth and today. Returns `null` for
|
|
572
|
-
* unparseable input or future dates so the UI can hide the badge
|
|
573
|
-
* gracefully rather than rendering "age -3".
|
|
8
|
+
* This module is a thin barrel — the implementations live in the
|
|
9
|
+
* `./journey-steps/` directory, split by step. Consumers import the
|
|
10
|
+
* same public symbols from here as before.
|
|
574
11
|
*/
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
return age >= 0 ? age : null;
|
|
585
|
-
}
|
|
586
|
-
function ageHint(min, max, messages) {
|
|
587
|
-
if (min != null && max != null) {
|
|
588
|
-
return formatMessage(messages.bookingJourney.configure.ageHintRange, { min, max });
|
|
589
|
-
}
|
|
590
|
-
if (min != null) {
|
|
591
|
-
return formatMessage(messages.bookingJourney.configure.ageHintMinimum, { min });
|
|
592
|
-
}
|
|
593
|
-
if (max != null) {
|
|
594
|
-
return formatMessage(messages.bookingJourney.configure.ageHintMaximum, { max });
|
|
595
|
-
}
|
|
596
|
-
return "";
|
|
597
|
-
}
|
|
598
|
-
// i18n-literal-ok Generic helper type signature, not user-visible copy.
|
|
599
|
-
function bucketBy(items, keyFn) {
|
|
600
|
-
const map = new Map();
|
|
601
|
-
for (const item of items) {
|
|
602
|
-
const key = keyFn(item);
|
|
603
|
-
let bucket = map.get(key);
|
|
604
|
-
if (!bucket) {
|
|
605
|
-
bucket = [];
|
|
606
|
-
map.set(key, bucket);
|
|
607
|
-
}
|
|
608
|
-
bucket.push(item);
|
|
609
|
-
}
|
|
610
|
-
return map;
|
|
611
|
-
}
|
|
612
|
-
function cryptoRowId() {
|
|
613
|
-
if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.randomUUID) {
|
|
614
|
-
return globalThis.crypto.randomUUID();
|
|
615
|
-
}
|
|
616
|
-
return `r_${Math.random().toString(36).slice(2, 10)}`;
|
|
617
|
-
}
|
|
12
|
+
export { AccommodationStep } from "./journey-steps/accommodation-step.js";
|
|
13
|
+
export { AddonsStep } from "./journey-steps/addons-step.js";
|
|
14
|
+
export { BillingStep } from "./journey-steps/billing-step.js";
|
|
15
|
+
export { DepartureStep, OptionsStep } from "./journey-steps/configure-steps.js";
|
|
16
|
+
export { DocumentsStep } from "./journey-steps/documents-step.js";
|
|
17
|
+
export { FinalizeControls, PaymentStep } from "./journey-steps/payment-step.js";
|
|
18
|
+
export { ReviewStep } from "./journey-steps/review-step.js";
|
|
19
|
+
export { JourneyWarnings } from "./journey-steps/shared.js";
|
|
20
|
+
export { TravelersStep } from "./journey-steps/travelers-step.js";
|