@voyantjs/flights-ui 0.20.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.
Files changed (74) hide show
  1. package/dist/components/airline-logo.d.ts +19 -0
  2. package/dist/components/airline-logo.d.ts.map +1 -0
  3. package/dist/components/airline-logo.js +18 -0
  4. package/dist/components/airport-combobox.d.ts +20 -0
  5. package/dist/components/airport-combobox.d.ts.map +1 -0
  6. package/dist/components/airport-combobox.js +29 -0
  7. package/dist/components/flight-baggage-step.d.ts +32 -0
  8. package/dist/components/flight-baggage-step.d.ts.map +1 -0
  9. package/dist/components/flight-baggage-step.js +106 -0
  10. package/dist/components/flight-billing-step.d.ts +69 -0
  11. package/dist/components/flight-billing-step.d.ts.map +1 -0
  12. package/dist/components/flight-billing-step.js +111 -0
  13. package/dist/components/flight-booking-journey.d.ts +31 -0
  14. package/dist/components/flight-booking-journey.d.ts.map +1 -0
  15. package/dist/components/flight-booking-journey.js +114 -0
  16. package/dist/components/flight-booking-ledger.d.ts +53 -0
  17. package/dist/components/flight-booking-ledger.d.ts.map +1 -0
  18. package/dist/components/flight-booking-ledger.js +94 -0
  19. package/dist/components/flight-booking-shell.d.ts +92 -0
  20. package/dist/components/flight-booking-shell.d.ts.map +1 -0
  21. package/dist/components/flight-booking-shell.js +486 -0
  22. package/dist/components/flight-contact-form.d.ts +16 -0
  23. package/dist/components/flight-contact-form.d.ts.map +1 -0
  24. package/dist/components/flight-contact-form.js +21 -0
  25. package/dist/components/flight-fare-upsell-step.d.ts +26 -0
  26. package/dist/components/flight-fare-upsell-step.d.ts.map +1 -0
  27. package/dist/components/flight-fare-upsell-step.js +141 -0
  28. package/dist/components/flight-filters-bar.d.ts +19 -0
  29. package/dist/components/flight-filters-bar.d.ts.map +1 -0
  30. package/dist/components/flight-filters-bar.js +90 -0
  31. package/dist/components/flight-itinerary.d.ts +28 -0
  32. package/dist/components/flight-itinerary.d.ts.map +1 -0
  33. package/dist/components/flight-itinerary.js +90 -0
  34. package/dist/components/flight-offer-detail.d.ts +21 -0
  35. package/dist/components/flight-offer-detail.d.ts.map +1 -0
  36. package/dist/components/flight-offer-detail.js +61 -0
  37. package/dist/components/flight-offer-row.d.ts +25 -0
  38. package/dist/components/flight-offer-row.d.ts.map +1 -0
  39. package/dist/components/flight-offer-row.js +74 -0
  40. package/dist/components/flight-order-confirmation.d.ts +13 -0
  41. package/dist/components/flight-order-confirmation.d.ts.map +1 -0
  42. package/dist/components/flight-order-confirmation.js +50 -0
  43. package/dist/components/flight-passenger-form.d.ts +49 -0
  44. package/dist/components/flight-passenger-form.d.ts.map +1 -0
  45. package/dist/components/flight-passenger-form.js +155 -0
  46. package/dist/components/flight-payment-selector.d.ts +13 -0
  47. package/dist/components/flight-payment-selector.d.ts.map +1 -0
  48. package/dist/components/flight-payment-selector.js +36 -0
  49. package/dist/components/flight-payment-step.d.ts +32 -0
  50. package/dist/components/flight-payment-step.d.ts.map +1 -0
  51. package/dist/components/flight-payment-step.js +82 -0
  52. package/dist/components/flight-search-form.d.ts +14 -0
  53. package/dist/components/flight-search-form.d.ts.map +1 -0
  54. package/dist/components/flight-search-form.js +56 -0
  55. package/dist/components/flight-seat-map.d.ts +32 -0
  56. package/dist/components/flight-seat-map.d.ts.map +1 -0
  57. package/dist/components/flight-seat-map.js +96 -0
  58. package/dist/components/flight-seats-step.d.ts +40 -0
  59. package/dist/components/flight-seats-step.d.ts.map +1 -0
  60. package/dist/components/flight-seats-step.js +211 -0
  61. package/dist/components/flight-services-step.d.ts +27 -0
  62. package/dist/components/flight-services-step.d.ts.map +1 -0
  63. package/dist/components/flight-services-step.js +110 -0
  64. package/dist/components/pax-cabin-popover.d.ts +18 -0
  65. package/dist/components/pax-cabin-popover.d.ts.map +1 -0
  66. package/dist/components/pax-cabin-popover.js +38 -0
  67. package/dist/components/popular-routes.d.ts +47 -0
  68. package/dist/components/popular-routes.d.ts.map +1 -0
  69. package/dist/components/popular-routes.js +126 -0
  70. package/dist/index.d.ts +26 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +23 -0
  73. package/package.json +77 -0
  74. package/src/styles.css +1 -0
@@ -0,0 +1,486 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button } from "@voyantjs/ui/components/button";
4
+ import { cn } from "@voyantjs/ui/lib/utils";
5
+ import { Check, ChevronLeft, ChevronRight } from "lucide-react";
6
+ import { useMemo, useState } from "react";
7
+ import { FlightBaggageStep } from "./flight-baggage-step";
8
+ import { emptyBillingValue, FlightBillingStep, validateBilling, } from "./flight-billing-step";
9
+ import { FlightBookingLedger, } from "./flight-booking-ledger";
10
+ import { FlightFareUpsellStep } from "./flight-fare-upsell-step";
11
+ import { FlightItinerary } from "./flight-itinerary";
12
+ import { FlightPassengerForm, validatePassengers, } from "./flight-passenger-form";
13
+ import { FlightPaymentStep, } from "./flight-payment-step";
14
+ import { FlightSeatsStep } from "./flight-seats-step";
15
+ import { FlightServicesStep } from "./flight-services-step";
16
+ const ALL_STEPS = [
17
+ { id: "review", label: "Review" },
18
+ { id: "fares", label: "Fare" },
19
+ { id: "passengers", label: "Passengers" },
20
+ { id: "bags", label: "Bags" },
21
+ { id: "seats", label: "Seats" },
22
+ { id: "services", label: "Services" },
23
+ { id: "billing", label: "Billing" },
24
+ { id: "payment", label: "Payment" },
25
+ { id: "confirm", label: "Confirm" },
26
+ ];
27
+ /**
28
+ * Steps that always render — the rest are adapter-capability-gated.
29
+ * - `fares` shows when the offer surfaces `fareBundles`
30
+ * - `bags` shows when the ancillary catalog has any baggage options
31
+ * - `seats` shows when a seat-map fetcher is wired
32
+ * - `services` shows when the ancillary catalog has assistance OR extras
33
+ *
34
+ * Travel documents live as an opt-in subsection on each passenger card —
35
+ * collapsed by default ("Add at check-in instead"), expanded when the
36
+ * operator wants to capture passport / national-id up front.
37
+ */
38
+ const ALWAYS_VISIBLE = new Set([
39
+ "review",
40
+ "passengers",
41
+ "billing",
42
+ "payment",
43
+ "confirm",
44
+ ]);
45
+ /**
46
+ * Multi-step booking shell with a sticky right-rail price ledger and a top
47
+ * stepper. Steps: review → passengers → bags → services → contact+payment
48
+ * → confirm. The shell owns the per-leg `FlightItinerarySelection` plus
49
+ * ancillary picks (baggage / assistance / extras), and synthesizes a
50
+ * combined `FlightOffer` (with both itineraries merged) when `onBook` is
51
+ * called — so the booking adapter sees a single offer with all legs intact.
52
+ */
53
+ export function FlightBookingShell({ selection, passengers, onBook, onBooked, onCancel, onEditOutbound, onEditReturn, carrierName, airportName, ancillaries, seatMaps, savedPaymentMethods, paymentCapabilities, documentsRequired, renderPassengerPicker, renderBillingPersonPicker, renderBillingOrgPicker, onSaveBillingDefaults, }) {
54
+ const [stepId, setStepId] = useState("review");
55
+ const [paxList, setPaxList] = useState([]);
56
+ const [billing, setBilling] = useState(emptyBillingValue);
57
+ const [payment, setPayment] = useState({ type: "hold" });
58
+ const [paymentSavedId, setPaymentSavedId] = useState(null);
59
+ const [submitting, setSubmitting] = useState(false);
60
+ const [error, setError] = useState(null);
61
+ const [baggage, setBaggage] = useState([]);
62
+ const [assistance, setAssistance] = useState([]);
63
+ const [extras, setExtras] = useState([]);
64
+ const [seats, setSeats] = useState([]);
65
+ const [fareBundles, setFareBundles] = useState([]);
66
+ const [seatsMode, setSeatsMode] = useState("auto");
67
+ const [sameForBothDirections, setSameForBothDirections] = useState(true);
68
+ const [sameFareForAllPax, setSameFareForAllPax] = useState(true);
69
+ // Compute the visible step list dynamically — adapter capability gating.
70
+ // Loading-aware: show optionals while the catalog is still in flight so the
71
+ // stepper doesn't grow when data lands; once loaded, hide what's empty.
72
+ const steps = useMemo(() => {
73
+ const hasFareBundles = (selection.outbound.fareBundles?.length ?? 0) > 0 ||
74
+ (selection.return?.fareBundles?.length ?? 0) > 0;
75
+ const cat = ancillaries?.outboundCatalog;
76
+ const catReturn = ancillaries?.returnCatalog;
77
+ const ancillariesLoading = !!ancillaries?.loading;
78
+ const hasBags = ancillariesLoading || (cat?.baggage.length ?? 0) > 0 || (catReturn?.baggage.length ?? 0) > 0;
79
+ const hasServices = ancillariesLoading ||
80
+ (cat?.assistance.length ?? 0) > 0 ||
81
+ (cat?.extras.length ?? 0) > 0 ||
82
+ (catReturn?.assistance.length ?? 0) > 0 ||
83
+ (catReturn?.extras.length ?? 0) > 0;
84
+ const hasSeats = !!seatMaps;
85
+ return ALL_STEPS.filter((s) => {
86
+ if (ALWAYS_VISIBLE.has(s.id))
87
+ return true;
88
+ if (s.id === "fares")
89
+ return hasFareBundles;
90
+ if (s.id === "bags")
91
+ return hasBags;
92
+ if (s.id === "seats")
93
+ return hasSeats;
94
+ if (s.id === "services")
95
+ return hasServices;
96
+ return true;
97
+ });
98
+ }, [
99
+ selection.outbound.fareBundles,
100
+ selection.return?.fareBundles,
101
+ ancillaries?.outboundCatalog,
102
+ ancillaries?.returnCatalog,
103
+ ancillaries?.loading,
104
+ seatMaps,
105
+ ]);
106
+ // If the visible-step list shrinks while we're sitting on a now-hidden
107
+ // step (e.g., catalog loaded with no services), fall back to the prior
108
+ // visible step. Done in render to keep state derived + free of effects.
109
+ const stepIdx = (() => {
110
+ const idx = steps.findIndex((s) => s.id === stepId);
111
+ return idx >= 0 ? idx : 0;
112
+ })();
113
+ const step = steps[stepIdx];
114
+ const combinedOffer = useMemo(() => mergeOffers(selection), [selection]);
115
+ const ancillarySelection = useMemo(() => {
116
+ if (baggage.length === 0 &&
117
+ assistance.length === 0 &&
118
+ extras.length === 0 &&
119
+ seats.length === 0 &&
120
+ fareBundles.length === 0) {
121
+ return undefined;
122
+ }
123
+ return {
124
+ ...(baggage.length > 0 ? { baggage } : {}),
125
+ ...(assistance.length > 0 ? { assistance } : {}),
126
+ ...(extras.length > 0 ? { extras } : {}),
127
+ ...(seats.length > 0 ? { seats } : {}),
128
+ ...(fareBundles.length > 0 ? { fareBundle: fareBundles } : {}),
129
+ };
130
+ }, [baggage, assistance, extras, seats, fareBundles]);
131
+ const paxErrors = validatePassengers(paxList);
132
+ const billingError = validateBilling(billing);
133
+ // Per-leg ledger line items derived from picks + catalogs + seat picks.
134
+ const { outboundExtras, returnExtras } = useMemo(() => buildLedgerExtras({
135
+ baggage,
136
+ extras,
137
+ assistance,
138
+ seats,
139
+ fareBundles,
140
+ outboundOffer: selection.outbound,
141
+ returnOffer: selection.return,
142
+ outboundCatalog: ancillaries?.outboundCatalog ?? null,
143
+ returnCatalog: ancillaries?.returnCatalog ?? null,
144
+ seatMaps,
145
+ }), [
146
+ baggage,
147
+ extras,
148
+ assistance,
149
+ seats,
150
+ fareBundles,
151
+ selection.outbound,
152
+ selection.return,
153
+ ancillaries?.outboundCatalog,
154
+ ancillaries?.returnCatalog,
155
+ seatMaps,
156
+ ]);
157
+ const completedSections = useMemo(() => {
158
+ const set = new Set();
159
+ set.add("flights");
160
+ // A section is "complete" once the user has navigated past it. We only
161
+ // mark sections that correspond to currently-visible steps.
162
+ const passedIdx = stepIdx;
163
+ const visibleIds = new Set(steps.map((s) => s.id));
164
+ const passed = (id) => {
165
+ if (!visibleIds.has(id))
166
+ return false;
167
+ const i = steps.findIndex((s) => s.id === id);
168
+ return i >= 0 && passedIdx > i;
169
+ };
170
+ if (passed("passengers"))
171
+ set.add("passengers");
172
+ if (passed("bags"))
173
+ set.add("bags");
174
+ if (passed("seats"))
175
+ set.add("seats");
176
+ if (passed("services"))
177
+ set.add("services");
178
+ if (passed("billing"))
179
+ set.add("billing");
180
+ if (stepIdx >= steps.length - 1)
181
+ set.add("payment");
182
+ return set;
183
+ }, [stepIdx, steps]);
184
+ if (!step)
185
+ return null;
186
+ const canContinue = (() => {
187
+ switch (step.id) {
188
+ case "review":
189
+ return true;
190
+ case "fares":
191
+ // Fare bundles are optional — keeping the base "Basic" fare is fine.
192
+ return true;
193
+ case "passengers":
194
+ return Object.keys(paxErrors).length === 0 && paxList.length > 0;
195
+ case "bags":
196
+ // Bags are optional — user may skip every passenger.
197
+ return true;
198
+ case "seats":
199
+ // Seats are optional under "skip" / "auto"; under "now" any picks are fine.
200
+ return true;
201
+ case "services":
202
+ return true;
203
+ case "billing":
204
+ return billingError == null;
205
+ case "payment":
206
+ return true;
207
+ case "confirm":
208
+ return true;
209
+ }
210
+ })();
211
+ const goNext = () => {
212
+ if (step?.id === "billing" && billing.saveAsDefault) {
213
+ onSaveBillingDefaults?.(billing);
214
+ }
215
+ const nextStep = steps[Math.min(steps.length - 1, stepIdx + 1)];
216
+ if (nextStep)
217
+ setStepId(nextStep.id);
218
+ };
219
+ const goBack = () => {
220
+ const prevStep = steps[Math.max(0, stepIdx - 1)];
221
+ if (prevStep)
222
+ setStepId(prevStep.id);
223
+ };
224
+ const submit = async () => {
225
+ setError(null);
226
+ setSubmitting(true);
227
+ try {
228
+ // Attach the structured billing address to the payment intent (when card)
229
+ // and surface the contact (email/phone) at the request level. Travel
230
+ // documents already live on each FlightPassenger.documents from the
231
+ // merged passenger form — no extra plumbing needed here.
232
+ const billingAddress = {
233
+ line1: billing.line1,
234
+ ...(billing.line2 ? { line2: billing.line2 } : {}),
235
+ city: billing.city,
236
+ ...(billing.region ? { region: billing.region } : {}),
237
+ ...(billing.postalCode ? { postalCode: billing.postalCode } : {}),
238
+ countryCode: billing.countryCode,
239
+ };
240
+ const paymentWithAddress = payment.type === "card" ? { ...payment, billingAddress } : payment;
241
+ const order = await onBook({
242
+ offerId: combinedOffer.offerId,
243
+ offer: combinedOffer,
244
+ passengers: paxList,
245
+ contact: { email: billing.email, phone: billing.phone },
246
+ paymentIntent: paymentWithAddress,
247
+ ...(ancillarySelection ? { ancillaries: ancillarySelection } : {}),
248
+ });
249
+ onBooked?.(order);
250
+ }
251
+ catch (err) {
252
+ setError(err instanceof Error ? err.message : String(err));
253
+ }
254
+ finally {
255
+ setSubmitting(false);
256
+ }
257
+ };
258
+ return (_jsxs("div", { className: "flex flex-col gap-6 lg:grid lg:grid-cols-[1fr_360px] lg:items-start", children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-5", children: [_jsx(Stepper, { steps: steps, currentIdx: stepIdx }), error && (_jsx("div", { className: "rounded-md border border-destructive/40 bg-destructive/5 p-3 text-destructive text-sm", children: error })), _jsxs("div", { className: "flex flex-col gap-4", children: [step.id === "review" && (_jsx(ReviewStep, { selection: selection, carrierName: carrierName, airportName: airportName })), step.id === "fares" && (_jsx(FlightFareUpsellStep, { outboundOffer: selection.outbound, returnOffer: selection.return, passengers: paxList, passengerCounts: passengers, value: fareBundles, onChange: setFareBundles, sameForAllPassengers: sameFareForAllPax, onSameForAllPassengersChange: setSameFareForAllPax })), step.id === "passengers" && (_jsx(FlightPassengerForm, { counts: passengers, value: paxList, onChange: setPaxList, documentsRequired: documentsRequired, renderPicker: renderPassengerPicker })), step.id === "bags" && (_jsx(FlightBaggageStep, { outboundCatalog: ancillaries?.outboundCatalog ?? null, returnCatalog: ancillaries?.returnCatalog ?? null, outboundOffer: selection.outbound, returnOffer: selection.return, passengers: paxList, passengerCounts: passengers, value: baggage, onChange: setBaggage, sameForBothDirections: sameForBothDirections, onSameForBothDirectionsChange: setSameForBothDirections, loading: ancillaries?.loading })), step.id === "seats" && (_jsx(FlightSeatsStep, { outboundOffer: selection.outbound, returnOffer: selection.return, passengers: paxList, passengerCounts: passengers, value: seats, onChange: setSeats, mode: seatsMode, onModeChange: setSeatsMode, getSeatMap: seatMaps?.getSeatMap ?? (() => ({ seatMap: null, error: "Seat maps unavailable" })) })), step.id === "services" && (_jsx(FlightServicesStep, { outboundCatalog: ancillaries?.outboundCatalog ?? null, returnCatalog: ancillaries?.returnCatalog ?? null, outboundOffer: selection.outbound, returnOffer: selection.return, passengers: paxList, passengerCounts: passengers, assistance: assistance, extras: extras, onAssistanceChange: setAssistance, onExtrasChange: setExtras, loading: ancillaries?.loading })), step.id === "billing" && (_jsx(FlightBillingStep, { value: billing, onChange: setBilling, eligiblePassengers: paxList
259
+ .filter((p) => p.type === "adult" && p.firstName.trim() !== "" && p.lastName.trim() !== "")
260
+ .map((p) => ({
261
+ id: p.passengerId,
262
+ firstName: p.firstName,
263
+ ...(p.middleName ? { middleName: p.middleName } : {}),
264
+ lastName: p.lastName,
265
+ })), renderPersonPicker: renderBillingPersonPicker, renderOrgPicker: renderBillingOrgPicker })), step.id === "payment" && (_jsx(FlightPaymentStep, { value: payment, onChange: setPayment, savedMethods: savedPaymentMethods?.methods ?? [], loadingSavedMethods: savedPaymentMethods?.loading, selectedSavedId: paymentSavedId, onSelectSaved: setPaymentSavedId, capabilities: paymentCapabilities })), step.id === "confirm" && (_jsx(ConfirmStep, { selection: selection, passengers: paxList, billing: billing, payment: payment, carrierName: carrierName, airportName: airportName }))] }), _jsxs("div", { className: "flex items-center justify-between border-t pt-4", children: [_jsxs(Button, { type: "button", variant: "ghost", onClick: () => (stepIdx === 0 ? onCancel?.() : goBack()), disabled: submitting, children: [_jsx(ChevronLeft, { className: "mr-1 h-4 w-4" }), stepIdx === 0 ? "Back to results" : "Back"] }), step.id === "confirm" ? (_jsx(Button, { onClick: submit, disabled: submitting, children: submitting ? "Booking…" : "Confirm booking" })) : (_jsxs(Button, { onClick: goNext, disabled: !canContinue, children: ["Continue", _jsx(ChevronRight, { className: "ml-1 h-4 w-4" })] }))] })] }), _jsx("div", { className: "lg:sticky lg:top-6", children: _jsx(FlightBookingLedger, { selection: selection, passengers: passengers, carrierName: carrierName, airportName: airportName, outboundExtras: outboundExtras, returnExtras: returnExtras, onEditOutbound: onEditOutbound, onEditReturn: onEditReturn, completedSections: completedSections }) })] }));
266
+ }
267
+ // ─────────────────────────────────────────────────────────────────────────────
268
+ // Steps
269
+ // ─────────────────────────────────────────────────────────────────────────────
270
+ function ReviewStep({ selection, carrierName, airportName, }) {
271
+ const isRoundTrip = !!selection.return;
272
+ return (_jsxs("div", { className: "flex flex-col gap-5 rounded-xl border bg-card p-5 shadow-sm", children: [_jsx("h2", { className: "font-semibold text-base", children: isRoundTrip ? "Review your trip" : "Review your flight" }), _jsx(FlightItinerary, { itinerary: selection.outbound.itineraries[0] ?? { segments: [] }, label: isRoundTrip ? "Outbound" : undefined, carrierName: carrierName, airportName: airportName }), selection.return && (_jsx(FlightItinerary, { itinerary: selection.return.itineraries[0] ?? { segments: [] }, label: "Return", carrierName: carrierName, airportName: airportName }))] }));
273
+ }
274
+ function ConfirmStep({ selection, passengers, billing, payment, carrierName, airportName, }) {
275
+ const docsCount = passengers.filter((p) => (p.documents?.length ?? 0) > 0).length;
276
+ const isRoundTrip = !!selection.return;
277
+ return (_jsxs("div", { className: "flex flex-col gap-5 rounded-xl border bg-card p-5 shadow-sm", children: [_jsx("h2", { className: "font-semibold text-base", children: "Confirm booking" }), _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(FlightItinerary, { itinerary: selection.outbound.itineraries[0] ?? { segments: [] }, label: isRoundTrip ? "Outbound" : undefined, compact: true, carrierName: carrierName, airportName: airportName }), selection.return && (_jsx(FlightItinerary, { itinerary: selection.return.itineraries[0] ?? { segments: [] }, label: "Return", compact: true, carrierName: carrierName, airportName: airportName }))] }), _jsx(Row, { label: "Passengers", children: passengers.length }), _jsx(Row, { label: "Documents", children: docsCount === passengers.length && passengers.length > 0
278
+ ? `All ${docsCount} added`
279
+ : docsCount > 0
280
+ ? `${docsCount} of ${passengers.length} added`
281
+ : "Add at check-in" }), _jsx(Row, { label: "Contact", children: billing.email || "—" }), _jsx(Row, { label: "Billed to", children: billing.mode === "company"
282
+ ? `${billing.companyName ?? "—"} · ${billing.vatNumber ?? ""}`
283
+ : `${billing.firstName} ${billing.lastName}`.trim() || "—" }), _jsx(Row, { label: "Payment", children: _jsx("span", { className: "capitalize", children: payment.type.replace("_", " ") }) }), _jsx("p", { className: "text-muted-foreground text-xs", children: "Submitting will hold seats with the connector and (depending on the chosen payment intent) either issue tickets immediately or open a ticketing window. The booking will appear under the order id once confirmed." })] }));
284
+ }
285
+ function Row({ label, children }) {
286
+ return (_jsxs("div", { className: "flex items-baseline justify-between border-b py-2 text-sm last:border-b-0", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsx("span", { children: children })] }));
287
+ }
288
+ // ─────────────────────────────────────────────────────────────────────────────
289
+ // Stepper
290
+ // ─────────────────────────────────────────────────────────────────────────────
291
+ function Stepper({ steps, currentIdx }) {
292
+ return (_jsx("ol", { className: "flex items-center gap-2 overflow-x-auto", children: steps.map((s, i) => {
293
+ const isActive = i === currentIdx;
294
+ const isComplete = i < currentIdx;
295
+ return (_jsxs("li", { className: "flex flex-1 items-center gap-2", children: [_jsx("div", { className: cn("flex h-7 w-7 shrink-0 items-center justify-center rounded-full border font-medium text-xs tabular-nums", isComplete && "border-primary bg-primary text-primary-foreground", isActive && !isComplete && "border-primary text-primary", !isActive && !isComplete && "border-border text-muted-foreground"), children: isComplete ? _jsx(Check, { className: "h-3.5 w-3.5" }) : i + 1 }), _jsx("span", { className: cn("truncate text-sm", isActive ? "font-medium text-foreground" : "text-muted-foreground"), children: s.label }), i < steps.length - 1 && _jsx("div", { className: "h-px flex-1 bg-border" })] }, s.id));
296
+ }) }));
297
+ }
298
+ // ─────────────────────────────────────────────────────────────────────────────
299
+ // Ledger line items from ancillary picks
300
+ // ─────────────────────────────────────────────────────────────────────────────
301
+ function buildLedgerExtras({ baggage, extras, assistance, seats, fareBundles, outboundOffer, returnOffer, outboundCatalog, returnCatalog, seatMaps, }) {
302
+ const lines = (sliceIndex, catalog, offer) => {
303
+ const out = [];
304
+ // Fare-bundle picks (first so they sit at the top of the leg block).
305
+ // Per-pax per-leg picks are aggregated by bundleId here — when everyone
306
+ // is on the same fare we render "Standard fare · €36 (2 pax)"; mixed
307
+ // picks render as separate lines per bundle.
308
+ if (offer?.fareBundles) {
309
+ const legPicks = fareBundles.filter((p) => p.sliceIndex === sliceIndex);
310
+ const agg = new Map();
311
+ for (const p of legPicks) {
312
+ const bundle = offer.fareBundles.find((b) => b.id === p.bundleId);
313
+ if (!bundle)
314
+ continue;
315
+ const prev = agg.get(bundle.id);
316
+ if (prev) {
317
+ prev.count += 1;
318
+ prev.price += Number(bundle.priceDelta.amount);
319
+ }
320
+ else {
321
+ agg.set(bundle.id, {
322
+ count: 1,
323
+ label: bundle.label,
324
+ price: Number(bundle.priceDelta.amount),
325
+ currency: bundle.priceDelta.currency,
326
+ });
327
+ }
328
+ }
329
+ for (const [, v] of agg) {
330
+ const labelSuffix = v.count > 1 ? ` (${v.count} pax)` : "";
331
+ out.push({
332
+ label: `${v.label} fare${labelSuffix}`,
333
+ amount: v.price > 0 ? { amount: v.price.toFixed(2), currency: v.currency } : undefined,
334
+ meta: v.price === 0 ? "Included" : undefined,
335
+ });
336
+ }
337
+ }
338
+ if (catalog) {
339
+ // Baggage — group by option label, sum quantities + prices.
340
+ const bagPicks = baggage.filter((b) => b.sliceIndex === sliceIndex);
341
+ const bagAgg = new Map();
342
+ for (const p of bagPicks) {
343
+ const opt = catalog.baggage.find((o) => o.id === p.optionId);
344
+ if (!opt)
345
+ continue;
346
+ const k = opt.id;
347
+ const prev = bagAgg.get(k);
348
+ const qty = p.quantity ?? 1;
349
+ if (prev) {
350
+ prev.count += qty;
351
+ prev.price += Number(opt.price.amount) * qty;
352
+ }
353
+ else {
354
+ bagAgg.set(k, {
355
+ count: qty,
356
+ label: opt.label,
357
+ price: Number(opt.price.amount) * qty,
358
+ currency: opt.price.currency,
359
+ });
360
+ }
361
+ }
362
+ for (const [, v] of bagAgg) {
363
+ out.push({
364
+ label: v.count > 1 ? `${v.count}× ${v.label}` : v.label,
365
+ amount: v.price > 0 ? { amount: v.price.toFixed(2), currency: v.currency } : undefined,
366
+ meta: v.price === 0 ? "Included" : undefined,
367
+ });
368
+ }
369
+ // Extras — same aggregation.
370
+ const extraPicks = extras.filter((b) => b.sliceIndex === sliceIndex);
371
+ const extAgg = new Map();
372
+ for (const p of extraPicks) {
373
+ const opt = catalog.extras.find((o) => o.id === p.optionId);
374
+ if (!opt)
375
+ continue;
376
+ const qty = p.quantity ?? 1;
377
+ const prev = extAgg.get(opt.id);
378
+ if (prev) {
379
+ prev.count += qty;
380
+ prev.price += Number(opt.price.amount) * qty;
381
+ }
382
+ else {
383
+ extAgg.set(opt.id, {
384
+ count: qty,
385
+ label: opt.label,
386
+ price: Number(opt.price.amount) * qty,
387
+ currency: opt.price.currency,
388
+ });
389
+ }
390
+ }
391
+ for (const [, v] of extAgg) {
392
+ out.push({
393
+ label: v.count > 1 ? `${v.count}× ${v.label}` : v.label,
394
+ amount: { amount: v.price.toFixed(2), currency: v.currency },
395
+ });
396
+ }
397
+ }
398
+ // Seats — sum across all segments belonging to this leg's offer.
399
+ if (offer && seatMaps) {
400
+ const segIds = new Set();
401
+ for (const itin of offer.itineraries) {
402
+ for (const seg of itin.segments)
403
+ segIds.add(seg.segmentId);
404
+ }
405
+ const seatPicks = seats.filter((p) => segIds.has(p.segmentId));
406
+ if (seatPicks.length > 0) {
407
+ let total = 0;
408
+ let currency = "EUR";
409
+ for (const pick of seatPicks) {
410
+ const slot = seatMaps.getSeatMap({ offerId: offer.offerId, segmentId: pick.segmentId });
411
+ const seat = slot.seatMap ? findSeatInMap(slot.seatMap, pick.seatNumber) : null;
412
+ if (seat?.price) {
413
+ total += Number(seat.price.amount);
414
+ currency = seat.price.currency;
415
+ }
416
+ }
417
+ out.push({
418
+ label: `${seatPicks.length} seat${seatPicks.length > 1 ? "s" : ""} picked`,
419
+ amount: total > 0 ? { amount: total.toFixed(2), currency } : undefined,
420
+ meta: total === 0 ? "Free" : undefined,
421
+ });
422
+ }
423
+ }
424
+ // Assistance — only on the outbound block (it's trip-wide, not per leg).
425
+ if (sliceIndex === 0 && assistance.length > 0) {
426
+ out.push({
427
+ label: `Special assistance (${assistance.length})`,
428
+ meta: "Free",
429
+ });
430
+ }
431
+ return out;
432
+ };
433
+ return {
434
+ outboundExtras: lines(0, outboundCatalog, outboundOffer),
435
+ returnExtras: lines(1, returnCatalog ?? outboundCatalog, returnOffer),
436
+ };
437
+ }
438
+ function findSeatInMap(map, seatNumber) {
439
+ for (const row of map.rows) {
440
+ for (const seat of row.seats) {
441
+ if (seat.seatNumber === seatNumber)
442
+ return seat;
443
+ }
444
+ }
445
+ return null;
446
+ }
447
+ // ─────────────────────────────────────────────────────────────────────────────
448
+ // Offer merging
449
+ // ─────────────────────────────────────────────────────────────────────────────
450
+ /**
451
+ * Synthesize a single combined `FlightOffer` from a per-leg selection. This
452
+ * gives the booking adapter the same shape it would have received from a
453
+ * single round-trip search (one offer, multiple itineraries) — so the
454
+ * existing `bookFlight` contract works unchanged. The combined `offerId` is
455
+ * `<outboundId>+<returnId>` so it round-trips through caching cleanly.
456
+ */
457
+ function mergeOffers(selection) {
458
+ const { outbound, return: ret } = selection;
459
+ if (!ret)
460
+ return outbound;
461
+ const currency = outbound.totalPrice.currency;
462
+ const amount = (Number(outbound.totalPrice.amount) + Number(ret.totalPrice.amount)).toFixed(2);
463
+ return {
464
+ offerId: `${outbound.offerId}+${ret.offerId}`,
465
+ source: outbound.source,
466
+ itineraries: [...outbound.itineraries, ...ret.itineraries],
467
+ fareBreakdowns: [...outbound.fareBreakdowns, ...ret.fareBreakdowns],
468
+ totalPrice: { amount, currency },
469
+ validatingCarrier: outbound.validatingCarrier,
470
+ expiresAt: pickEarliest(outbound.expiresAt, ret.expiresAt),
471
+ lastTicketingDate: pickEarliest(outbound.lastTicketingDate, ret.lastTicketingDate),
472
+ instantTicketing: (outbound.instantTicketing ?? false) && (ret.instantTicketing ?? false),
473
+ providerData: {
474
+ ...(outbound.providerData ?? {}),
475
+ ...(ret.providerData ?? {}),
476
+ __mergedFrom: { outbound: outbound.offerId, return: ret.offerId },
477
+ },
478
+ };
479
+ }
480
+ function pickEarliest(a, b) {
481
+ if (!a)
482
+ return b;
483
+ if (!b)
484
+ return a;
485
+ return new Date(a).getTime() <= new Date(b).getTime() ? a : b;
486
+ }
@@ -0,0 +1,16 @@
1
+ export interface FlightContactValue {
2
+ email?: string;
3
+ phone?: string;
4
+ }
5
+ export interface FlightContactFormProps {
6
+ value: FlightContactValue;
7
+ onChange: (next: FlightContactValue) => void;
8
+ }
9
+ /**
10
+ * Booking contact details — used by the connector to send confirmation +
11
+ * any operational disruption notices. Email is required by most providers;
12
+ * phone is recommended but optional.
13
+ */
14
+ export declare function FlightContactForm({ value, onChange }: FlightContactFormProps): import("react/jsx-runtime").JSX.Element;
15
+ export declare function validateContact(value: FlightContactValue): string | null;
16
+ //# sourceMappingURL=flight-contact-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flight-contact-form.d.ts","sourceRoot":"","sources":["../../src/components/flight-contact-form.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,kBAAkB,CAAA;IACzB,QAAQ,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAA;CAC7C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CA4C5E;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,kBAAkB,GAAG,MAAM,GAAG,IAAI,CAKxE"}
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Input } from "@voyantjs/ui/components/input";
4
+ import { Label } from "@voyantjs/ui/components/label";
5
+ import { Mail, Phone } from "lucide-react";
6
+ /**
7
+ * Booking contact details — used by the connector to send confirmation +
8
+ * any operational disruption notices. Email is required by most providers;
9
+ * phone is recommended but optional.
10
+ */
11
+ export function FlightContactForm({ value, onChange }) {
12
+ return (_jsxs("div", { className: "rounded-lg border bg-card p-4 shadow-sm", children: [_jsx("h3", { className: "mb-3 font-medium text-sm", children: "Contact details" }), _jsx("p", { className: "mb-4 text-xs text-muted-foreground", children: "Used by the airline to send confirmation, schedule changes, and operational notices for this booking." }), _jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-[11px] uppercase tracking-wider text-muted-foreground", children: ["Email ", _jsx("span", { className: "ml-0.5 text-destructive", children: "*" })] }), _jsxs("div", { className: "relative", children: [_jsx(Mail, { className: "-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" }), _jsx(Input, { type: "email", autoComplete: "email", value: value.email ?? "", onChange: (e) => onChange({ ...value, email: e.target.value }), placeholder: "traveler@example.com", className: "pl-9" })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { className: "text-[11px] uppercase tracking-wider text-muted-foreground", children: "Phone" }), _jsxs("div", { className: "relative", children: [_jsx(Phone, { className: "-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" }), _jsx(Input, { type: "tel", autoComplete: "tel", value: value.phone ?? "", onChange: (e) => onChange({ ...value, phone: e.target.value }), placeholder: "+1 555 123 4567", className: "pl-9" })] })] })] })] }));
13
+ }
14
+ export function validateContact(value) {
15
+ if (!value.email?.trim())
16
+ return "Email is required";
17
+ // Loose email check — adapters do their own validation
18
+ if (!/.+@.+\..+/.test(value.email))
19
+ return "Email looks invalid";
20
+ return null;
21
+ }
@@ -0,0 +1,26 @@
1
+ import type { AncillarySelection, FlightOffer, FlightPassenger, PassengerCounts } from "@voyantjs/flights/contract/types";
2
+ type FareBundlePicks = NonNullable<AncillarySelection["fareBundle"]>;
3
+ export interface FlightFareUpsellStepProps {
4
+ outboundOffer: FlightOffer;
5
+ returnOffer?: FlightOffer;
6
+ /** Filled passenger entries — used for per-pax labels. */
7
+ passengers: FlightPassenger[];
8
+ /** Pax counts when the form hasn't been filled yet (fallback labels). */
9
+ passengerCounts: PassengerCounts;
10
+ value: FareBundlePicks;
11
+ onChange: (next: FareBundlePicks) => void;
12
+ /** Default-on toggle: one pick applies to every passenger on the leg. */
13
+ sameForAllPassengers: boolean;
14
+ onSameForAllPassengersChange: (next: boolean) => void;
15
+ }
16
+ /**
17
+ * Per-pax per-leg branded-fare upsell step. For multi-pax bookings the
18
+ * "Same fare for all passengers" toggle (default ON) collapses the picker
19
+ * back to one card grid per leg, keeping the common case ("everyone on
20
+ * Standard") to one click. Toggling off splits each leg into per-pax card
21
+ * grids — useful for the "Adult 1 Plus, Adult 2 Basic" case full-service
22
+ * carriers + B2B agency bookings actually exercise.
23
+ */
24
+ export declare function FlightFareUpsellStep({ outboundOffer, returnOffer, passengers, passengerCounts, value, onChange, sameForAllPassengers, onSameForAllPassengersChange, }: FlightFareUpsellStepProps): import("react/jsx-runtime").JSX.Element;
25
+ export {};
26
+ //# sourceMappingURL=flight-fare-upsell-step.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flight-fare-upsell-step.d.ts","sourceRoot":"","sources":["../../src/components/flight-fare-upsell-step.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAElB,WAAW,EACX,eAAe,EACf,eAAe,EAChB,MAAM,kCAAkC,CAAA;AAMzC,KAAK,eAAe,GAAG,WAAW,CAAC,kBAAkB,CAAC,YAAY,CAAC,CAAC,CAAA;AAGpE,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,0DAA0D;IAC1D,UAAU,EAAE,eAAe,EAAE,CAAA;IAC7B,yEAAyE;IACzE,eAAe,EAAE,eAAe,CAAA;IAChC,KAAK,EAAE,eAAe,CAAA;IACtB,QAAQ,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;IACzC,yEAAyE;IACzE,oBAAoB,EAAE,OAAO,CAAA;IAC7B,4BAA4B,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;CACtD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,aAAa,EACb,WAAW,EACX,UAAU,EACV,eAAe,EACf,KAAK,EACL,QAAQ,EACR,oBAAoB,EACpB,4BAA4B,GAC7B,EAAE,yBAAyB,2CA8F3B"}