@voyantjs/bookings-ui 0.19.0 → 0.21.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/booking-dialog.d.ts.map +1 -1
- package/dist/components/booking-dialog.js +10 -1
- package/dist/components/booking-group-section.d.ts +11 -1
- package/dist/components/booking-group-section.d.ts.map +1 -1
- package/dist/components/booking-group-section.js +16 -2
- package/dist/components/booking-item-list.d.ts.map +1 -1
- package/dist/components/booking-item-list.js +71 -7
- package/dist/components/booking-list.d.ts.map +1 -1
- package/dist/components/booking-list.js +11 -3
- package/dist/components/booking-payments-summary.d.ts +28 -1
- package/dist/components/booking-payments-summary.d.ts.map +1 -1
- package/dist/components/booking-payments-summary.js +66 -11
- package/dist/components/traveler-list.d.ts +2 -1
- package/dist/components/traveler-list.d.ts.map +1 -1
- package/dist/components/traveler-list.js +126 -12
- package/dist/i18n/en.d.ts +12 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +13 -1
- package/dist/i18n/messages.d.ts +14 -1
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/i18n/provider.d.ts +24 -0
- package/dist/i18n/provider.d.ts.map +1 -1
- package/dist/i18n/ro.d.ts +12 -0
- package/dist/i18n/ro.d.ts.map +1 -1
- package/dist/i18n/ro.js +13 -1
- package/dist/journey/components/booking-journey.d.ts +3 -0
- package/dist/journey/components/booking-journey.d.ts.map +1 -0
- package/dist/journey/components/booking-journey.js +376 -0
- package/dist/journey/components/contract-preview-dialog.d.ts +47 -0
- package/dist/journey/components/contract-preview-dialog.d.ts.map +1 -0
- package/dist/journey/components/contract-preview-dialog.js +119 -0
- package/dist/journey/components/journey-steps.d.ts +47 -0
- package/dist/journey/components/journey-steps.d.ts.map +1 -0
- package/dist/journey/components/journey-steps.js +582 -0
- package/dist/journey/components/side-panel.d.ts +12 -0
- package/dist/journey/components/side-panel.d.ts.map +1 -0
- package/dist/journey/components/side-panel.js +172 -0
- package/dist/journey/components/step-header.d.ts +7 -0
- package/dist/journey/components/step-header.d.ts.map +1 -0
- package/dist/journey/components/step-header.js +28 -0
- package/dist/journey/index.d.ts +18 -0
- package/dist/journey/index.d.ts.map +1 -0
- package/dist/journey/index.js +17 -0
- package/dist/journey/lib/draft-state.d.ts +34 -0
- package/dist/journey/lib/draft-state.d.ts.map +1 -0
- package/dist/journey/lib/draft-state.js +54 -0
- package/dist/journey/types.d.ts +248 -0
- package/dist/journey/types.d.ts.map +1 -0
- package/dist/journey/types.js +17 -0
- package/package.json +31 -17
- package/src/styles.css +11 -0
|
@@ -0,0 +1,582 @@
|
|
|
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 { patchBilling, patchConfigure, patchPaxCount, setAccommodation, setAddons, setPayment, setTravelers, totalPax, } from "../lib/draft-state.js";
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────
|
|
15
|
+
// Configure
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────
|
|
17
|
+
export function ConfigureStep({ draft, setDraft, shape, }) {
|
|
18
|
+
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Configure" }) }), _jsxs(CardContent, { className: "space-y-6", children: [_jsx(PaxBands, { draft: draft, setDraft: setDraft, shape: shape }), _jsx(DepartureFields, { draft: draft, setDraft: setDraft, shape: shape })] })] }));
|
|
19
|
+
}
|
|
20
|
+
function PaxBands({ draft, setDraft, shape }) {
|
|
21
|
+
return (_jsxs("div", { className: "space-y-3", children: [_jsx(Label, { children: "Travelers" }), _jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: shape.paxBands.map((band) => {
|
|
22
|
+
const value = draft.configure.pax?.[band.code] ?? 0;
|
|
23
|
+
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) })) : 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));
|
|
24
|
+
}) }), _jsx(PaxValidation, { draft: draft, shape: shape })] }));
|
|
25
|
+
}
|
|
26
|
+
function PaxValidation({ draft, shape, }) {
|
|
27
|
+
const total = totalPax(draft);
|
|
28
|
+
const { min, max } = shape.paxBandsAllowedTotal;
|
|
29
|
+
if (total < min) {
|
|
30
|
+
return (_jsxs("p", { className: "text-sm text-amber-600", children: ["Add at least ", min, " traveler", min === 1 ? "" : "s", " to continue."] }));
|
|
31
|
+
}
|
|
32
|
+
if (total > max) {
|
|
33
|
+
return _jsxs("p", { className: "text-sm text-destructive", children: ["Max ", max, " travelers per booking."] });
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function DepartureFields({ draft, setDraft, shape }) {
|
|
38
|
+
const subSteps = shape.configureSubSteps ?? [];
|
|
39
|
+
// Render every sub-step kind the descriptor declares. Cruise
|
|
40
|
+
// (cabin-category, cabin-number) lands here in Phase F.
|
|
41
|
+
if (subSteps.length === 0) {
|
|
42
|
+
return _jsx(DepartureBasic, { draft: draft, setDraft: setDraft });
|
|
43
|
+
}
|
|
44
|
+
return (_jsx("div", { className: "space-y-4", children: subSteps.map((sub) => {
|
|
45
|
+
// Sub-step kinds are unique per descriptor — kind serves as
|
|
46
|
+
// a stable key.
|
|
47
|
+
if (sub.kind === "departure") {
|
|
48
|
+
return _jsx(DepartureBasic, { draft: draft, setDraft: setDraft }, "departure");
|
|
49
|
+
}
|
|
50
|
+
if (sub.kind === "date-range") {
|
|
51
|
+
return (_jsx(DateRangeFields, { draft: draft, setDraft: setDraft, minNights: sub.minNights, maxNights: sub.maxNights }, "date-range"));
|
|
52
|
+
}
|
|
53
|
+
if (sub.kind === "cabin-category") {
|
|
54
|
+
return (_jsx(CabinCategoryFields, { draft: draft, setDraft: setDraft, categories: sub.categories }, "cabin-category"));
|
|
55
|
+
}
|
|
56
|
+
if (sub.kind === "cabin-number") {
|
|
57
|
+
return (_jsx(CabinNumberFields, { draft: draft, setDraft: setDraft, perCategory: sub.perCategory }, "cabin-number"));
|
|
58
|
+
}
|
|
59
|
+
if (sub.kind === "air-arrangement") {
|
|
60
|
+
return _jsx(AirArrangementFields, { draft: draft, setDraft: setDraft }, "air-arrangement");
|
|
61
|
+
}
|
|
62
|
+
// "occupancy" — already rendered as PaxBands above; no sub-row.
|
|
63
|
+
return null;
|
|
64
|
+
}) }));
|
|
65
|
+
}
|
|
66
|
+
function DepartureBasic({ draft, setDraft, }) {
|
|
67
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(DateField, { id: "bj-departure-date", label: "Departure date", 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: "Time (optional)" }), _jsx(Input, { id: "bj-departure-time", type: "time", value: draft.configure.departureTime ?? "", onChange: (e) => setDraft(patchConfigure(draft, { departureTime: e.target.value })) })] })] }));
|
|
68
|
+
}
|
|
69
|
+
function DateRangeFields({ draft, setDraft, minNights, maxNights, }) {
|
|
70
|
+
const range = draft.configure.dateRange;
|
|
71
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(DateField, { id: "bj-checkin", label: "Check-in", value: range?.checkIn ?? "", onChange: (v) => setDraft(patchConfigure(draft, {
|
|
72
|
+
dateRange: { checkIn: v, checkOut: range?.checkOut ?? "" },
|
|
73
|
+
})), range: "future" }), _jsx(DateField, { id: "bj-checkout", label: `Check-out (${minNights}–${maxNights} nights)`, value: range?.checkOut ?? "", onChange: (v) => setDraft(patchConfigure(draft, {
|
|
74
|
+
dateRange: { checkIn: range?.checkIn ?? "", checkOut: v },
|
|
75
|
+
})), range: "future" })] }));
|
|
76
|
+
}
|
|
77
|
+
function CabinCategoryFields({ draft, setDraft, categories, }) {
|
|
78
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "Cabin category" }), _jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: categories.map((cat) => {
|
|
79
|
+
const selected = draft.configure.cabinCategoryId === cat.id;
|
|
80
|
+
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));
|
|
81
|
+
}) })] }));
|
|
82
|
+
}
|
|
83
|
+
function AirArrangementFields({ draft, setDraft, }) {
|
|
84
|
+
const current = draft.configure.airArrangement;
|
|
85
|
+
const options = [
|
|
86
|
+
{
|
|
87
|
+
value: "cruise_line",
|
|
88
|
+
label: "Cruise-line-arranged flights",
|
|
89
|
+
description: "The cruise line books your flights in a coordinated package. Operator follows up with the air desk.",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
value: "independent",
|
|
93
|
+
label: "Independent flights",
|
|
94
|
+
description: "Book flights yourself or via a separate flight booking line.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
value: "none",
|
|
98
|
+
label: "No flights needed",
|
|
99
|
+
description: "Regional cruise / driving to the port.",
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "Air arrangements" }), _jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-3", children: options.map((opt) => {
|
|
103
|
+
const selected = current === opt.value;
|
|
104
|
+
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));
|
|
105
|
+
}) })] }));
|
|
106
|
+
}
|
|
107
|
+
function CabinNumberFields({ draft, setDraft, perCategory, }) {
|
|
108
|
+
const catId = draft.configure.cabinCategoryId;
|
|
109
|
+
if (!catId)
|
|
110
|
+
return null;
|
|
111
|
+
const cabins = perCategory[catId] ?? [];
|
|
112
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "Cabin number" }), _jsx("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-4", children: cabins.map((cabin) => {
|
|
113
|
+
const selected = draft.configure.cabinNumberId === cabin.id;
|
|
114
|
+
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));
|
|
115
|
+
}) })] }));
|
|
116
|
+
}
|
|
117
|
+
// ─────────────────────────────────────────────────────────────────
|
|
118
|
+
// Billing
|
|
119
|
+
// ─────────────────────────────────────────────────────────────────
|
|
120
|
+
export function BillingStep({ draft, setDraft, renderLeadContactPicker, renderExtras, }) {
|
|
121
|
+
const billing = draft.billing;
|
|
122
|
+
const apply = (contact) => {
|
|
123
|
+
setDraft(patchBilling(draft, {
|
|
124
|
+
contact: {
|
|
125
|
+
firstName: contact.firstName,
|
|
126
|
+
lastName: contact.lastName,
|
|
127
|
+
email: contact.email ?? "",
|
|
128
|
+
phone: contact.phone,
|
|
129
|
+
},
|
|
130
|
+
}));
|
|
131
|
+
};
|
|
132
|
+
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Billing & lead contact" }) }), _jsxs(CardContent, { className: "space-y-6", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "Buyer type" }), _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" }), " Individual (B2C)"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx(RadioGroupItem, { value: "B2B" }), " Company (B2B)"] })] })] }), 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: "First name", value: billing.contact.firstName, onChange: (v) => setDraft(patchBilling(draft, { contact: { ...billing.contact, firstName: v } })) }), _jsx(Field, { id: "bj-billing-lastName", label: "Last name", value: billing.contact.lastName, onChange: (v) => setDraft(patchBilling(draft, { contact: { ...billing.contact, lastName: v } })) }), _jsx(Field, { id: "bj-billing-email", label: "Email", type: "email", value: billing.contact.email, onChange: (v) => setDraft(patchBilling(draft, { contact: { ...billing.contact, email: v } })) }), _jsx(PhoneField, { id: "bj-billing-phone", label: "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: "Address line 1", value: billing.address.line1 ?? "", onChange: (v) => setDraft(patchBilling(draft, { address: { ...billing.address, line1: v } })) }), _jsx(Field, { id: "bj-billing-line2", label: "Address line 2 (optional)", value: billing.address.line2 ?? "", onChange: (v) => setDraft(patchBilling(draft, { address: { ...billing.address, line2: v } })) }), _jsx(Field, { id: "bj-billing-city", label: "City", value: billing.address.city ?? "", onChange: (v) => setDraft(patchBilling(draft, { address: { ...billing.address, city: v } })) }), _jsx(Field, { id: "bj-billing-postal", label: "Postal code", 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: "Country" }), _jsx(CountryCombobox, { value: billing.address.country ?? null, onChange: (code) => setDraft(patchBilling(draft, {
|
|
133
|
+
address: { ...billing.address, country: code ?? "" },
|
|
134
|
+
})) })] })] }), billing.buyerType === "B2B" ? (_jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(Field, { id: "bj-billing-companyName", label: "Company name", value: billing.company?.name ?? "", onChange: (v) => setDraft(patchBilling(draft, {
|
|
135
|
+
company: { ...(billing.company ?? { name: "" }), name: v },
|
|
136
|
+
})) }), _jsx(Field, { id: "bj-billing-vatId", label: "VAT id", value: billing.company?.vatId ?? "", onChange: (v) => setDraft(patchBilling(draft, {
|
|
137
|
+
company: { ...(billing.company ?? { name: "" }), vatId: v },
|
|
138
|
+
})) })] })) : null, renderExtras ? _jsx("div", { children: renderExtras() }) : null] })] }));
|
|
139
|
+
}
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────
|
|
141
|
+
// Travelers
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────
|
|
143
|
+
export function TravelersStep({ draft, setDraft, shape, renderTravelerContactPicker, }) {
|
|
144
|
+
const total = totalPax(draft);
|
|
145
|
+
// Auto-resize the travelers list to match pax counts. Newly-added
|
|
146
|
+
// rows pick a band based on the lowest-count band that's not yet
|
|
147
|
+
// saturated — naive but predictable.
|
|
148
|
+
const ensured = ensureTravelerRows(draft, total, shape);
|
|
149
|
+
if (ensured !== draft.travelers) {
|
|
150
|
+
setDraft(setTravelers(draft, ensured));
|
|
151
|
+
}
|
|
152
|
+
// Bands are bookkeeping for the pricing engine — the user only
|
|
153
|
+
// sees a flat list of travelers. Adding a traveler bumps the
|
|
154
|
+
// adult-band counter by default; the row's band is reassigned
|
|
155
|
+
// automatically once a DOB is entered (see TravelerCard's onDob).
|
|
156
|
+
const defaultAddBand = shape.paxBands.find((b) => b.code === "adult") ?? shape.paxBands[0];
|
|
157
|
+
const totalCap = shape.paxBandsAllowedTotal.max;
|
|
158
|
+
const canAddMore = ensured.length < totalCap && Boolean(defaultAddBand);
|
|
159
|
+
const removeTraveler = (rowIdx) => {
|
|
160
|
+
const band = ensured[rowIdx]?.band;
|
|
161
|
+
if (!band)
|
|
162
|
+
return;
|
|
163
|
+
const current = draft.configure.pax?.[band] ?? 0;
|
|
164
|
+
const spec = shape.paxBands.find((b) => b.code === band);
|
|
165
|
+
if (spec && current <= spec.minCount)
|
|
166
|
+
return;
|
|
167
|
+
setDraft(patchPaxCount(draft, band, current - 1));
|
|
168
|
+
};
|
|
169
|
+
const addTraveler = () => {
|
|
170
|
+
if (!defaultAddBand)
|
|
171
|
+
return;
|
|
172
|
+
const current = draft.configure.pax?.[defaultAddBand.code] ?? 0;
|
|
173
|
+
if (current >= defaultAddBand.maxCount)
|
|
174
|
+
return;
|
|
175
|
+
setDraft(patchPaxCount(draft, defaultAddBand.code, current + 1));
|
|
176
|
+
};
|
|
177
|
+
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Travelers" }) }), _jsxs(CardContent, { className: "space-y-4", children: [ensured.length === 0 ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "Pick traveler counts in the Configure step to start adding details." })) : null, ensured.map((traveler, idx) => {
|
|
178
|
+
const apply = (contact) => {
|
|
179
|
+
const next = [...ensured];
|
|
180
|
+
next[idx] = {
|
|
181
|
+
...next[idx],
|
|
182
|
+
firstName: contact.firstName,
|
|
183
|
+
lastName: contact.lastName,
|
|
184
|
+
email: contact.email,
|
|
185
|
+
phone: contact.phone,
|
|
186
|
+
};
|
|
187
|
+
setDraft(setTravelers(draft, next));
|
|
188
|
+
};
|
|
189
|
+
const bandSpec = shape.paxBands.find((b) => b.code === traveler.band);
|
|
190
|
+
const currentBandCount = draft.configure.pax?.[traveler.band] ?? 0;
|
|
191
|
+
const canRemove = !bandSpec || currentBandCount > bandSpec.minCount;
|
|
192
|
+
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));
|
|
193
|
+
}), canAddMore ? (_jsx("div", { className: "border-t pt-3", children: _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: addTraveler, children: "+ Add traveler" }) })) : null] })] }));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* One traveler block — name, optional contact, age, and any
|
|
197
|
+
* descriptor-driven document fields. Honors `appliesToBands` so DOB
|
|
198
|
+
* is required for child / infant bands and adult-only fields like
|
|
199
|
+
* passport drop off the form for non-adult travelers.
|
|
200
|
+
*
|
|
201
|
+
* The first three canonical keys (firstName/lastName/email) are
|
|
202
|
+
* rendered inline as fixed widgets; the rest comes off
|
|
203
|
+
* `shape.travelerFields` so verticals can extend the set without
|
|
204
|
+
* touching the wizard.
|
|
205
|
+
*/
|
|
206
|
+
function TravelerCard({ idx, traveler, shape, draft, setDraft, renderTravelerContactPicker, apply, onRemove, }) {
|
|
207
|
+
// The band is bookkeeping — only show fields that apply to this
|
|
208
|
+
// band per the descriptor, but never expose the band tag in the UI.
|
|
209
|
+
// The user just sees a flat traveler list.
|
|
210
|
+
const applicableFields = shape.travelerFields.filter((f) => {
|
|
211
|
+
if (!f.appliesToBands || f.appliesToBands.length === 0)
|
|
212
|
+
return true;
|
|
213
|
+
return f.appliesToBands.includes(traveler.band);
|
|
214
|
+
});
|
|
215
|
+
const dobField = applicableFields.find((f) => f.key === "dateOfBirth");
|
|
216
|
+
const phoneField = applicableFields.find((f) => f.key === "phone");
|
|
217
|
+
const dynamicFields = applicableFields.filter((f) => !["firstName", "lastName", "email", "phone", "dateOfBirth"].includes(f.key));
|
|
218
|
+
// Live age from DOB — surfaces in the header so the user gets
|
|
219
|
+
// feedback as they pick a date.
|
|
220
|
+
const computedAge = traveler.dateOfBirth ? computeAge(traveler.dateOfBirth) : null;
|
|
221
|
+
// The DOB drives band assignment. When the user picks a date that
|
|
222
|
+
// would land in a different band, we silently reband the row and
|
|
223
|
+
// shift the pax counters so the engine quotes the right price.
|
|
224
|
+
// No "Move to X" prompts — the system just does the right thing.
|
|
225
|
+
const onDobChange = (v) => {
|
|
226
|
+
const age = v ? computeAge(v) : null;
|
|
227
|
+
let next = updateTravelerImmutable(draft, idx, { dateOfBirth: v });
|
|
228
|
+
if (age != null) {
|
|
229
|
+
const targetBand = shape.paxBands.find((b) => {
|
|
230
|
+
if (b.minAge != null && age < b.minAge)
|
|
231
|
+
return false;
|
|
232
|
+
if (b.maxAge != null && age > b.maxAge)
|
|
233
|
+
return false;
|
|
234
|
+
return true;
|
|
235
|
+
});
|
|
236
|
+
if (targetBand && targetBand.code !== traveler.band) {
|
|
237
|
+
next = rebandTraveler(next, idx, traveler.band, targetBand.code);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
setDraft(next);
|
|
241
|
+
};
|
|
242
|
+
// Out-of-range warning — only fires when the entered DOB doesn't
|
|
243
|
+
// fit ANY of the descriptor's bands (e.g. older than the supplier
|
|
244
|
+
// accepts). We can't auto-fix that one — the booking would be
|
|
245
|
+
// rejected and the user needs to know.
|
|
246
|
+
const ageOutOfBounds = computedAge != null &&
|
|
247
|
+
!shape.paxBands.some((b) => {
|
|
248
|
+
if (b.minAge != null && computedAge < b.minAge)
|
|
249
|
+
return false;
|
|
250
|
+
if (b.maxAge != null && computedAge > b.maxAge)
|
|
251
|
+
return false;
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
// Quick-fill from billing — useful when the lead booker is also a
|
|
255
|
+
// traveler (the common B2C case). Doesn't touch travel-document
|
|
256
|
+
// fields since those are personal to each traveler.
|
|
257
|
+
const billingContact = draft.billing.contact;
|
|
258
|
+
const canCopyFromBilling = Boolean(billingContact.firstName || billingContact.email);
|
|
259
|
+
const copyFromBilling = () => {
|
|
260
|
+
updateTraveler(draft, setDraft, idx, {
|
|
261
|
+
firstName: billingContact.firstName,
|
|
262
|
+
lastName: billingContact.lastName,
|
|
263
|
+
email: billingContact.email || undefined,
|
|
264
|
+
phone: billingContact.phone || undefined,
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
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: ["Traveler ", idx + 1, computedAge != null ? (_jsxs("span", { className: "text-muted-foreground font-normal", children: [" \u00B7 age ", computedAge] })) : null] }) }), _jsxs("div", { className: "flex items-center gap-2", children: [canCopyFromBilling ? (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: copyFromBilling, children: "Copy from billing" })) : null, renderTravelerContactPicker
|
|
268
|
+
? renderTravelerContactPicker({ rowIndex: idx, apply })
|
|
269
|
+
: null, onRemove ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "text-destructive hover:text-destructive", onClick: onRemove, children: "Remove" })) : null] })] }), _jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsx(Field, { id: `bj-trav-${idx}-first`, label: "First name", value: traveler.firstName, onChange: (v) => updateTraveler(draft, setDraft, idx, { firstName: v }) }), _jsx(Field, { id: `bj-trav-${idx}-last`, label: "Last name", 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: "Email", type: "email", value: traveler.email ?? "", onChange: (v) => updateTraveler(draft, setDraft, idx, { email: v }) })) : null, phoneField ? (_jsx(PhoneField, { id: `bj-trav-${idx}-phone`, 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`, 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 Age ", computedAge, " is outside the accepted range for this product."] })) : null] })) : null, dynamicFields.map((field) => {
|
|
270
|
+
const value = traveler.documents?.[field.key] ?? "";
|
|
271
|
+
const onFieldChange = (v) => updateTraveler(draft, setDraft, idx, {
|
|
272
|
+
documents: { ...traveler.documents, [field.key]: v },
|
|
273
|
+
});
|
|
274
|
+
const labelText = field.label + (field.required ? " *" : "");
|
|
275
|
+
if (field.type === "select" && field.options) {
|
|
276
|
+
return (_jsx(SelectField, { id: `bj-trav-${idx}-${field.key}`, label: labelText, value: value, options: field.options, onChange: onFieldChange }, field.key));
|
|
277
|
+
}
|
|
278
|
+
if (field.type === "date") {
|
|
279
|
+
return (_jsx(DateField, { id: `bj-trav-${idx}-${field.key}`, label: labelText, value: value, onChange: onFieldChange, range: field.key === "documentExpiry" ? "document" : "future" }, field.key));
|
|
280
|
+
}
|
|
281
|
+
return (_jsx(Field, { id: `bj-trav-${idx}-${field.key}`, label: labelText, type: "text", value: value, onChange: onFieldChange }, field.key));
|
|
282
|
+
})] })] }));
|
|
283
|
+
}
|
|
284
|
+
function ensureTravelerRows(draft, total, shape) {
|
|
285
|
+
// Return the same reference when no resize is needed — the caller
|
|
286
|
+
// uses identity equality to decide whether to call setDraft, and a
|
|
287
|
+
// fresh array on every render would loop infinitely (set during
|
|
288
|
+
// render → re-render → set again).
|
|
289
|
+
if (draft.travelers.length === total)
|
|
290
|
+
return draft.travelers;
|
|
291
|
+
const list = [...draft.travelers];
|
|
292
|
+
while (list.length > total)
|
|
293
|
+
list.pop();
|
|
294
|
+
while (list.length < total) {
|
|
295
|
+
const idx = list.length;
|
|
296
|
+
const band = pickBandForIndex(draft, idx, shape);
|
|
297
|
+
list.push({
|
|
298
|
+
rowId: cryptoRowId(),
|
|
299
|
+
firstName: "",
|
|
300
|
+
lastName: "",
|
|
301
|
+
band,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return list;
|
|
305
|
+
}
|
|
306
|
+
function pickBandForIndex(draft, idx, shape) {
|
|
307
|
+
// Pick by remaining quota: distribute travelers in band order based
|
|
308
|
+
// on counts in `configure.pax`.
|
|
309
|
+
let cursor = 0;
|
|
310
|
+
for (const band of shape.paxBands) {
|
|
311
|
+
const count = draft.configure.pax?.[band.code] ?? 0;
|
|
312
|
+
if (idx < cursor + count) {
|
|
313
|
+
return band.code ?? "adult";
|
|
314
|
+
}
|
|
315
|
+
cursor += count;
|
|
316
|
+
}
|
|
317
|
+
return "adult";
|
|
318
|
+
}
|
|
319
|
+
function updateTraveler(draft, setDraft, idx, patch) {
|
|
320
|
+
setDraft(updateTravelerImmutable(draft, idx, patch));
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Patch one traveler row and return a new draft. Useful when the
|
|
324
|
+
* caller wants to chain multiple draft updates (e.g. setting DOB +
|
|
325
|
+
* rebanding) into a single setDraft call.
|
|
326
|
+
*/
|
|
327
|
+
function updateTravelerImmutable(draft, idx, patch) {
|
|
328
|
+
const next = [...draft.travelers];
|
|
329
|
+
if (!next[idx])
|
|
330
|
+
return draft;
|
|
331
|
+
next[idx] = { ...next[idx], ...patch };
|
|
332
|
+
return setTravelers(draft, next);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Move one traveler row from one band to another, keeping the pax
|
|
336
|
+
* counters consistent. Used by the auto-reband path on DOB change.
|
|
337
|
+
*/
|
|
338
|
+
function rebandTraveler(draft, idx, fromBand, toBand) {
|
|
339
|
+
const fromCount = draft.configure.pax?.[fromBand] ?? 0;
|
|
340
|
+
const toCount = draft.configure.pax?.[toBand] ?? 0;
|
|
341
|
+
let next = updateTravelerImmutable(draft, idx, { band: toBand });
|
|
342
|
+
next = patchPaxCount(next, fromBand, Math.max(0, fromCount - 1));
|
|
343
|
+
next = patchPaxCount(next, toBand, toCount + 1);
|
|
344
|
+
return next;
|
|
345
|
+
}
|
|
346
|
+
// ─────────────────────────────────────────────────────────────────
|
|
347
|
+
// Accommodation
|
|
348
|
+
// ─────────────────────────────────────────────────────────────────
|
|
349
|
+
export function AccommodationStep({ draft, setDraft, shape }) {
|
|
350
|
+
const subSteps = shape.accommodation?.subSteps ?? [];
|
|
351
|
+
const rooms = shape.accommodation?.roomOptions ?? [];
|
|
352
|
+
const accommodation = draft.accommodation ?? { rooms: [], travelerAssignments: {} };
|
|
353
|
+
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Accommodation" }) }), _jsx(CardContent, { className: "space-y-4", children: rooms.length === 0 && subSteps.length === 0 ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "No accommodation options for this product." })) : (_jsxs("div", { className: "space-y-3", children: [rooms.map((room) => {
|
|
354
|
+
const current = accommodation.rooms.find((r) => r.optionUnitId === room.id);
|
|
355
|
+
const ratePlans = room.ratePlans ?? [];
|
|
356
|
+
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: () => {
|
|
357
|
+
const list = accommodation.rooms.filter((r) => r.optionUnitId !== room.id);
|
|
358
|
+
const qty = (current?.quantity ?? 0) - 1;
|
|
359
|
+
if (qty > 0) {
|
|
360
|
+
list.push({
|
|
361
|
+
optionUnitId: room.id,
|
|
362
|
+
quantity: qty,
|
|
363
|
+
ratePlanId: current?.ratePlanId,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
setDraft(setAccommodation(draft, { ...accommodation, rooms: list }));
|
|
367
|
+
}, children: "\u2212" }), _jsx("span", { className: "min-w-6 text-center", children: current?.quantity ?? 0 }), _jsx(Button, { variant: "outline", size: "sm", type: "button", onClick: () => {
|
|
368
|
+
const list = accommodation.rooms.filter((r) => r.optionUnitId !== room.id);
|
|
369
|
+
const qty = (current?.quantity ?? 0) + 1;
|
|
370
|
+
// Auto-select the only rate plan when there's
|
|
371
|
+
// exactly one — saves a click on the common case.
|
|
372
|
+
const ratePlanId = current?.ratePlanId ??
|
|
373
|
+
(ratePlans.length === 1 ? ratePlans[0]?.id : undefined);
|
|
374
|
+
list.push({ optionUnitId: room.id, quantity: qty, ratePlanId });
|
|
375
|
+
setDraft(setAccommodation(draft, { ...accommodation, rooms: list }));
|
|
376
|
+
}, children: "+" })] })] }), current && current.quantity > 0 && ratePlans.length > 0 ? (_jsx(RatePlanPicker, { roomId: room.id, ratePlans: ratePlans, selected: current.ratePlanId, onSelect: (planId) => {
|
|
377
|
+
const list = accommodation.rooms.map((r) => r.optionUnitId === room.id ? { ...r, ratePlanId: planId } : r);
|
|
378
|
+
setDraft(setAccommodation(draft, { ...accommodation, rooms: list }));
|
|
379
|
+
} })) : null] }, room.id));
|
|
380
|
+
}), subSteps.map((sub) => sub.kind === "extensions" ? (_jsxs("div", { className: "rounded border p-3 text-muted-foreground text-sm", children: [sub.options.length, " extension option", sub.options.length === 1 ? "" : "s", " ", "available \u2014 UI lands in Phase F."] }, "extensions")) : null)] })) })] }));
|
|
381
|
+
}
|
|
382
|
+
function RatePlanPicker({ roomId, ratePlans, selected, onSelect, }) {
|
|
383
|
+
return (_jsxs("div", { className: "space-y-2 border-t pt-3", children: [_jsx(Label, { htmlFor: `bj-rate-plan-${roomId}`, children: "Rate plan" }), _jsx("div", { className: "space-y-2", children: ratePlans.map((plan) => {
|
|
384
|
+
const isSelected = plan.id === selected;
|
|
385
|
+
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: ["Cancellation: ", plan.cancellationPolicy] })) : null, plan.inclusions && plan.inclusions.length > 0 ? (_jsxs("div", { className: "text-muted-foreground text-xs", children: ["Includes: ", plan.inclusions.join(", ")] })) : null] }, plan.id));
|
|
386
|
+
}) })] }));
|
|
387
|
+
}
|
|
388
|
+
// ─────────────────────────────────────────────────────────────────
|
|
389
|
+
// Add-ons
|
|
390
|
+
// ─────────────────────────────────────────────────────────────────
|
|
391
|
+
export function AddonsStep({ draft, setDraft, shape }) {
|
|
392
|
+
const flat = shape.addons?.catalog ?? [];
|
|
393
|
+
const groups = shape.addons?.groups ?? [];
|
|
394
|
+
const all = [...flat, ...groups.flatMap((g) => g.items)];
|
|
395
|
+
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Add-ons" }) }), _jsxs(CardContent, { className: "space-y-4", children: [all.length === 0 ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "No add-ons available for this product." })) : null, groups.map((group) => {
|
|
396
|
+
// Group by port/day when the descriptor asks — cruise
|
|
397
|
+
// excursions arrive grouped by port name.
|
|
398
|
+
const buckets = group.groupBy === "port" || group.groupBy === "day"
|
|
399
|
+
? bucketBy(group.items, (i) => i.groupKey ?? "Other")
|
|
400
|
+
: new Map([["", group.items]]);
|
|
401
|
+
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));
|
|
402
|
+
}), 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] })] }));
|
|
403
|
+
}
|
|
404
|
+
function AddonRow({ draft, setDraft, item, }) {
|
|
405
|
+
const current = draft.addons.find((a) => a.extraId === item.id);
|
|
406
|
+
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: () => {
|
|
407
|
+
const list = draft.addons.filter((a) => a.extraId !== item.id);
|
|
408
|
+
const qty = (current?.quantity ?? 0) - 1;
|
|
409
|
+
if (qty > 0)
|
|
410
|
+
list.push({ extraId: item.id, quantity: qty });
|
|
411
|
+
setDraft(setAddons(draft, 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 = draft.addons.filter((a) => a.extraId !== item.id);
|
|
414
|
+
const qty = (current?.quantity ?? 0) + 1;
|
|
415
|
+
list.push({ extraId: item.id, quantity: qty });
|
|
416
|
+
setDraft(setAddons(draft, list));
|
|
417
|
+
}, children: "+" })] })] }));
|
|
418
|
+
}
|
|
419
|
+
// ─────────────────────────────────────────────────────────────────
|
|
420
|
+
// Payment
|
|
421
|
+
// ─────────────────────────────────────────────────────────────────
|
|
422
|
+
export function PaymentStep({ draft, setDraft, shape, capabilities, renderProviderStep, }) {
|
|
423
|
+
// The descriptor lists what the *engine* supports; capabilities
|
|
424
|
+
// narrow further to what the *deployment* turned on. Both must
|
|
425
|
+
// accept an intent for the user to see it.
|
|
426
|
+
const allowed = shape.paymentIntents.filter((i) => isCapabilityEnabled(i, capabilities));
|
|
427
|
+
const intent = draft.payment.intent;
|
|
428
|
+
// Snap the draft's intent to the first allowed value when the
|
|
429
|
+
// current pick isn't on the list — covers descriptor changes
|
|
430
|
+
// mid-flow (e.g. owned→sourced switch narrows the list).
|
|
431
|
+
if (allowed.length > 0 && !allowed.includes(intent)) {
|
|
432
|
+
setDraft(setPayment(draft, { ...draft.payment, intent: allowed[0] }));
|
|
433
|
+
}
|
|
434
|
+
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Payment" }) }), _jsxs(CardContent, { className: "space-y-4", children: [allowed.length === 0 ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "No payment methods are available for this booking." })) : (_jsx(RadioGroup, { value: intent, onValueChange: (v) => setDraft(setPayment(draft, { ...draft.payment, intent: v })), className: "grid grid-cols-1 gap-2", children: allowed.map((i) => {
|
|
435
|
+
const meta = intentMeta(i);
|
|
436
|
+
const selected = i === intent;
|
|
437
|
+
return (_jsxs("label", { className: "flex cursor-pointer items-start gap-3 rounded border p-3 text-sm transition-colors " +
|
|
438
|
+
(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));
|
|
439
|
+
}) })), intent === "card" ? (renderProviderStep ? (_jsx("div", { children: renderProviderStep({
|
|
440
|
+
intent,
|
|
441
|
+
schedule: draft.payment.schedule,
|
|
442
|
+
capabilities,
|
|
443
|
+
}) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "You'll be redirected to our secure payment page after confirming the booking." }))) : 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: "We'll send your details to the operator without locking inventory or taking payment. They'll get back to you with availability and a quote \u2014 typically within one business day." })) : null] })] }));
|
|
444
|
+
}
|
|
445
|
+
function BankTransferDetails({ capabilities, }) {
|
|
446
|
+
const note = capabilities.config?.bankTransferNote;
|
|
447
|
+
return (_jsxs("div", { className: "rounded border bg-muted/30 p-3 text-sm", children: [_jsx("p", { className: "font-medium", children: "Bank transfer instructions" }), _jsx("p", { className: "text-muted-foreground text-xs", children: typeof note === "string" && note.length > 0
|
|
448
|
+
? note
|
|
449
|
+
: "After you submit, you'll receive an email with our bank details and a payment reference. Inventory is held pending payment." })] }));
|
|
450
|
+
}
|
|
451
|
+
function isCapabilityEnabled(intent, capabilities) {
|
|
452
|
+
switch (intent) {
|
|
453
|
+
case "card":
|
|
454
|
+
return capabilities.acceptsCard;
|
|
455
|
+
case "hold":
|
|
456
|
+
return capabilities.acceptsHold;
|
|
457
|
+
case "bank_transfer":
|
|
458
|
+
return capabilities.acceptsBankTransfer === true;
|
|
459
|
+
case "ticket_on_credit":
|
|
460
|
+
return capabilities.acceptsTicketOnCredit;
|
|
461
|
+
case "inquiry":
|
|
462
|
+
return capabilities.acceptsInquiry === true;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function intentMeta(intent) {
|
|
466
|
+
switch (intent) {
|
|
467
|
+
case "card":
|
|
468
|
+
return {
|
|
469
|
+
label: "Pay by card",
|
|
470
|
+
description: "Charged immediately. Inventory is reserved on confirmation.",
|
|
471
|
+
};
|
|
472
|
+
case "bank_transfer":
|
|
473
|
+
return {
|
|
474
|
+
label: "Bank transfer",
|
|
475
|
+
description: "We'll send you bank details and a reference. Inventory is held while we wait for the transfer.",
|
|
476
|
+
};
|
|
477
|
+
case "hold":
|
|
478
|
+
return {
|
|
479
|
+
label: "Hold for now",
|
|
480
|
+
description: "Reserve inventory without paying. The operator follows up to collect payment.",
|
|
481
|
+
};
|
|
482
|
+
case "ticket_on_credit":
|
|
483
|
+
return {
|
|
484
|
+
label: "Agency credit account",
|
|
485
|
+
description: "Charge against an agency's credit line. Operator surfaces only.",
|
|
486
|
+
};
|
|
487
|
+
case "inquiry":
|
|
488
|
+
return {
|
|
489
|
+
label: "Send as inquiry",
|
|
490
|
+
description: "No payment, no inventory hold. The operator gets back to you with availability and a quote.",
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// ─────────────────────────────────────────────────────────────────
|
|
495
|
+
// Review
|
|
496
|
+
// ─────────────────────────────────────────────────────────────────
|
|
497
|
+
export function ReviewStep({ draft, setDraft, isCommitting, onConfirm, renderExtras, surface, }) {
|
|
498
|
+
const isPublic = surface === "public";
|
|
499
|
+
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Review & confirm" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("div", { className: "font-medium", children: "Lead contact" }), _jsxs("div", { className: "text-muted-foreground text-sm", children: [draft.billing.contact.firstName, " ", draft.billing.contact.lastName, " \u2014", " ", draft.billing.contact.email] })] }), _jsxs("div", { children: [_jsx("div", { className: "font-medium", children: "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: "Notes" }), _jsx(Textarea, { id: "bj-customer-notes", placeholder: "Anything we should know? (allergies, accessibility needs, special occasion\u2026)", 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: "Internal notes (operator-only)" }), _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" }), "Confirming\u2026"] })) : ("Confirm booking") })] })] }));
|
|
500
|
+
}
|
|
501
|
+
// ─────────────────────────────────────────────────────────────────
|
|
502
|
+
// Helpers
|
|
503
|
+
// ─────────────────────────────────────────────────────────────────
|
|
504
|
+
function Field({ id, label, value, onChange, type, placeholder, }) {
|
|
505
|
+
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) })] }));
|
|
506
|
+
}
|
|
507
|
+
function PhoneField({ id, label, value, onChange, }) {
|
|
508
|
+
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) : "") })] }));
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Date field that uses the shared `<DatePicker />` from
|
|
512
|
+
* `@voyantjs/ui` with a month + year dropdown caption so users can
|
|
513
|
+
* jump across decades without arrow-clicking. The `range` hint picks
|
|
514
|
+
* a reasonable startMonth/endMonth window per use case:
|
|
515
|
+
*
|
|
516
|
+
* - `"past"` — DOB-style picks (today back ~120 years)
|
|
517
|
+
* - `"future"` — departure / check-in / check-out (today forward ~5 years)
|
|
518
|
+
* - `"document"` — passport / ID expiry (today forward ~20 years)
|
|
519
|
+
*/
|
|
520
|
+
function DateField({ id, label, value, onChange, range = "future", }) {
|
|
521
|
+
const today = new Date();
|
|
522
|
+
const todayMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
523
|
+
const startMonth = range === "past"
|
|
524
|
+
? new Date(today.getFullYear() - 120, 0, 1)
|
|
525
|
+
: range === "document"
|
|
526
|
+
? todayMonth
|
|
527
|
+
: todayMonth;
|
|
528
|
+
const endMonth = range === "past"
|
|
529
|
+
? new Date(today.getFullYear() + 1, 11, 1)
|
|
530
|
+
: range === "document"
|
|
531
|
+
? new Date(today.getFullYear() + 20, 11, 1)
|
|
532
|
+
: new Date(today.getFullYear() + 5, 11, 1);
|
|
533
|
+
const defaultMonth = range === "past" && !value ? new Date(today.getFullYear() - 30, 0, 1) : undefined;
|
|
534
|
+
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" })] }));
|
|
535
|
+
}
|
|
536
|
+
function SelectField({ id, label, value, options, onChange, }) {
|
|
537
|
+
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: "Select\u2026" }), options.map((opt) => (_jsx("option", { value: opt.value, children: opt.label }, opt.value)))] })] }));
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Years between an ISO date-of-birth and today. Returns `null` for
|
|
541
|
+
* unparseable input or future dates so the UI can hide the badge
|
|
542
|
+
* gracefully rather than rendering "age -3".
|
|
543
|
+
*/
|
|
544
|
+
function computeAge(dob) {
|
|
545
|
+
const d = new Date(dob);
|
|
546
|
+
if (Number.isNaN(d.getTime()))
|
|
547
|
+
return null;
|
|
548
|
+
const now = new Date();
|
|
549
|
+
let age = now.getFullYear() - d.getFullYear();
|
|
550
|
+
const m = now.getMonth() - d.getMonth();
|
|
551
|
+
if (m < 0 || (m === 0 && now.getDate() < d.getDate()))
|
|
552
|
+
age -= 1;
|
|
553
|
+
return age >= 0 ? age : null;
|
|
554
|
+
}
|
|
555
|
+
function ageHint(min, max) {
|
|
556
|
+
if (min != null && max != null)
|
|
557
|
+
return `${min}–${max}y`;
|
|
558
|
+
if (min != null)
|
|
559
|
+
return `${min}y+`;
|
|
560
|
+
if (max != null)
|
|
561
|
+
return `up to ${max}y`;
|
|
562
|
+
return "";
|
|
563
|
+
}
|
|
564
|
+
function bucketBy(items, keyFn) {
|
|
565
|
+
const map = new Map();
|
|
566
|
+
for (const item of items) {
|
|
567
|
+
const key = keyFn(item);
|
|
568
|
+
let bucket = map.get(key);
|
|
569
|
+
if (!bucket) {
|
|
570
|
+
bucket = [];
|
|
571
|
+
map.set(key, bucket);
|
|
572
|
+
}
|
|
573
|
+
bucket.push(item);
|
|
574
|
+
}
|
|
575
|
+
return map;
|
|
576
|
+
}
|
|
577
|
+
function cryptoRowId() {
|
|
578
|
+
if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.randomUUID) {
|
|
579
|
+
return globalThis.crypto.randomUUID();
|
|
580
|
+
}
|
|
581
|
+
return `r_${Math.random().toString(36).slice(2, 10)}`;
|
|
582
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SidePanelState } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Right-rail summary panel. Shows what's being booked, an
|
|
4
|
+
* accordion-per-step recap of the user's input, and the live
|
|
5
|
+
* pricing breakdown at the bottom. The current step's accordion is
|
|
6
|
+
* expanded by default; users can click any step to peek at what
|
|
7
|
+
* they've filled in elsewhere.
|
|
8
|
+
*/
|
|
9
|
+
export declare function PriceSidePanel({ pricing, isQuoting, invalidReason, entitySummary, currentStep, steps, draft, className, }: SidePanelState & {
|
|
10
|
+
className?: string;
|
|
11
|
+
}): React.ReactElement;
|
|
12
|
+
//# sourceMappingURL=side-panel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"side-panel.d.ts","sourceRoot":"","sources":["../../../src/journey/components/side-panel.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAqC,cAAc,EAAE,MAAM,aAAa,CAAA;AAEpF;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,SAAS,EACT,aAAa,EACb,aAAa,EACb,WAAW,EACX,KAAK,EACL,KAAK,EACL,SAAS,GACV,EAAE,cAAc,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,KAAK,CAAC,YAAY,CAsD9D"}
|