@voyantjs/flights-ui 0.30.7 → 0.31.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/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @voyantjs/flights-ui
2
+
3
+ Importable React UI components and page compositions for Voyant flights. Bundler-consumed (Vite, Next.js, webpack, etc.).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @voyantjs/flights-ui @voyantjs/flights-react @voyantjs/flights @voyantjs/crm-react @voyantjs/ui @tanstack/react-query react react-dom
9
+ ```
10
+
11
+ `@voyantjs/ui` provides the design-system primitives. `@voyantjs/flights-react`
12
+ provides the data-layer hooks. CRM-backed contact and billing pickers use
13
+ `@voyantjs/crm-react`.
14
+
15
+ ## Pages
16
+
17
+ `FlightsPage` renders the search, filter, per-leg round-trip picker, offer
18
+ detail sheet, and booking handoff. The route owns URL validation and navigation:
19
+
20
+ ```tsx
21
+ import { FlightsPage } from "@voyantjs/flights-ui"
22
+
23
+ <FlightsPage
24
+ search={search}
25
+ onSearchChange={(next, options) => updateRouteSearch(next, options)}
26
+ onBookOffer={({ outboundOfferId, returnOfferId, passengers, cabin }) =>
27
+ goToBooking({ outboundOfferId, returnOfferId, passengers, cabin })
28
+ }
29
+ />
30
+ ```
31
+
32
+ `FlightBookingPage` renders the repricing, ancillaries, seat-map, passenger,
33
+ billing, payment, and confirmation flow around `FlightBookingShell`. The route
34
+ or app supplies booking and navigation callbacks:
35
+
36
+ ```tsx
37
+ import { FlightBookingPage } from "@voyantjs/flights-ui"
38
+
39
+ <FlightBookingPage
40
+ outboundOfferId={offerId}
41
+ returnOfferId={returnOfferId}
42
+ passengers={{ adults: 1, children: 0, infants: 0 }}
43
+ paymentCapabilities={{ chargeSavedCard: false, newCard: false }}
44
+ onBackToSearch={() => navigateToFlights()}
45
+ onBook={(request) => bookFlight(request)}
46
+ onBooked={(order) => navigateToBooking(order.orderId)}
47
+ />
48
+ ```
49
+
50
+ Router behavior, booking submission, payment capabilities, billing-default
51
+ persistence, and contact creation are callbacks or slots so applications keep
52
+ deployment-specific ownership.
53
+
54
+ ## I18n
55
+
56
+ Components render English by default. To localize them, wrap your UI in
57
+ `FlightsUiMessagesProvider` and import only the locales your app supports.
58
+
59
+ ```tsx
60
+ import { FlightsUiMessagesProvider } from "@voyantjs/flights-ui"
61
+ import { flightsUiEn } from "@voyantjs/flights-ui/i18n/en"
62
+ import { flightsUiRo } from "@voyantjs/flights-ui/i18n/ro"
63
+ ```
64
+
65
+ English-only apps should import only `./i18n/en`. Bilingual apps can import
66
+ `./i18n/en` and `./i18n/ro`.
@@ -0,0 +1,19 @@
1
+ import type { BillingValue } from "./flight-billing-step.js";
2
+ export interface BillingPersonPickerProps {
3
+ apply: (prefill: Partial<BillingValue>) => void;
4
+ onPersonSelected?: (personId: string | null) => void;
5
+ }
6
+ /**
7
+ * Billing-step CRM person picker. It searches `/v1/crm/people`, prefers a
8
+ * billing/primary address, and maps the selected person into `BillingValue`.
9
+ */
10
+ export declare function BillingPersonPicker({ apply, onPersonSelected }: BillingPersonPickerProps): import("react/jsx-runtime").JSX.Element;
11
+ export interface BillingOrgPickerProps {
12
+ apply: (prefill: Partial<BillingValue>) => void;
13
+ }
14
+ /**
15
+ * Billing-step CRM organization picker. It searches organizations and maps
16
+ * the selected organization, address, and contact points into `BillingValue`.
17
+ */
18
+ export declare function BillingOrgPicker({ apply }: BillingOrgPickerProps): import("react/jsx-runtime").JSX.Element;
19
+ //# sourceMappingURL=billing-pickers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-pickers.d.ts","sourceRoot":"","sources":["../../src/components/billing-pickers.tsx"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAE5D,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAA;IAC/C,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;CACrD;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,gBAAgB,EAAE,EAAE,wBAAwB,2CA6ExF;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAA;CAChD;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,EAAE,qBAAqB,2CA0EhE"}
@@ -0,0 +1,148 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useOrganizations, usePeople } from "@voyantjs/crm-react";
4
+ import { Button } from "@voyantjs/ui/components/button";
5
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@voyantjs/ui/components/command";
6
+ import { Popover, PopoverContent, PopoverTrigger } from "@voyantjs/ui/components/popover";
7
+ import { Building2, ChevronDown, Users } from "lucide-react";
8
+ import { useState } from "react";
9
+ import { useFlightsUiMessagesOrDefault } from "../i18n/index.js";
10
+ /**
11
+ * Billing-step CRM person picker. It searches `/v1/crm/people`, prefers a
12
+ * billing/primary address, and maps the selected person into `BillingValue`.
13
+ */
14
+ export function BillingPersonPicker({ apply, onPersonSelected }) {
15
+ const messages = useFlightsUiMessagesOrDefault().billingPickers;
16
+ const [open, setOpen] = useState(false);
17
+ const [search, setSearch] = useState("");
18
+ const peopleQuery = usePeople({
19
+ search: search.trim() || undefined,
20
+ limit: 30,
21
+ enabled: open,
22
+ });
23
+ const people = (peopleQuery.data?.data ?? []).filter((person) => isAdult(person.birthday));
24
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsxs(PopoverTrigger, { render: _jsx(Button, { type: "button", variant: "outline", size: "sm", className: "gap-2" }), children: [_jsx(Users, { className: "h-3.5 w-3.5" }), messages.personTrigger, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsx(PopoverContent, { className: "w-[340px] p-0", align: "end", children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { value: search, onValueChange: setSearch, placeholder: messages.personSearchPlaceholder }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: peopleQuery.isLoading ? messages.peopleSearching : messages.peopleEmpty }), _jsx(CommandGroup, { children: people.map((person) => {
25
+ const fullName = `${person.firstName} ${person.lastName}`.trim();
26
+ return (_jsx(CommandItem, { value: `${fullName} ${person.email ?? ""}`, onSelect: async () => {
27
+ const address = await fetchPreferredAddress("person", person.id);
28
+ apply({
29
+ mode: "personal",
30
+ firstName: person.firstName,
31
+ lastName: person.lastName,
32
+ email: person.email ?? "",
33
+ phone: person.phone ?? undefined,
34
+ line1: address?.line1 ?? "",
35
+ line2: address?.line2 ?? undefined,
36
+ city: address?.city ?? "",
37
+ region: address?.region ?? undefined,
38
+ postalCode: address?.postalCode ?? undefined,
39
+ countryCode: address?.country ?? "",
40
+ });
41
+ onPersonSelected?.(person.id);
42
+ setOpen(false);
43
+ setSearch("");
44
+ }, children: _jsxs("div", { className: "flex min-w-0 flex-1 flex-col leading-tight", children: [_jsx("span", { className: "truncate font-medium text-sm", children: fullName || messages.emptyName }), person.email && (_jsx("span", { className: "truncate text-muted-foreground text-xs", children: person.email }))] }) }, person.id));
45
+ }) })] })] }) })] }));
46
+ }
47
+ /**
48
+ * Billing-step CRM organization picker. It searches organizations and maps
49
+ * the selected organization, address, and contact points into `BillingValue`.
50
+ */
51
+ export function BillingOrgPicker({ apply }) {
52
+ const messages = useFlightsUiMessagesOrDefault().billingPickers;
53
+ const [open, setOpen] = useState(false);
54
+ const [search, setSearch] = useState("");
55
+ const orgsQuery = useOrganizations({
56
+ search: search.trim() || undefined,
57
+ limit: 30,
58
+ enabled: open,
59
+ });
60
+ const orgs = orgsQuery.data?.data ?? [];
61
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsxs(PopoverTrigger, { render: _jsx(Button, { type: "button", variant: "outline", size: "sm", className: "gap-2" }), children: [_jsx(Building2, { className: "h-3.5 w-3.5" }), messages.orgTrigger, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsx(PopoverContent, { className: "w-[340px] p-0", align: "end", children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { value: search, onValueChange: setSearch, placeholder: messages.orgSearchPlaceholder }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: orgsQuery.isLoading ? messages.orgsSearching : messages.orgsEmpty }), _jsx(CommandGroup, { children: orgs.map((org) => (_jsx(CommandItem, { value: org.name, onSelect: async () => {
62
+ const [address, contactPoints] = await Promise.all([
63
+ fetchPreferredAddress("organization", org.id),
64
+ fetchContactPoints("organization", org.id),
65
+ ]);
66
+ apply({
67
+ mode: "company",
68
+ companyName: org.name,
69
+ ...(org.vatNumber ? { vatNumber: org.vatNumber } : {}),
70
+ email: contactPoints.email ?? "",
71
+ ...(contactPoints.phone ? { phone: contactPoints.phone } : {}),
72
+ line1: address?.line1 ?? "",
73
+ line2: address?.line2 ?? undefined,
74
+ city: address?.city ?? "",
75
+ region: address?.region ?? undefined,
76
+ postalCode: address?.postalCode ?? undefined,
77
+ countryCode: address?.country ?? "",
78
+ });
79
+ setOpen(false);
80
+ setSearch("");
81
+ }, children: _jsxs("div", { className: "flex min-w-0 flex-1 flex-col leading-tight", children: [_jsx("span", { className: "truncate font-medium text-sm", children: org.name }), org.legalName && (_jsx("span", { className: "truncate text-muted-foreground text-xs", children: org.legalName }))] }) }, org.id))) })] })] }) })] }));
82
+ }
83
+ function isAdult(birthday) {
84
+ if (!birthday)
85
+ return true;
86
+ const dob = new Date(birthday);
87
+ if (Number.isNaN(dob.getTime()))
88
+ return true;
89
+ const now = new Date();
90
+ let years = now.getFullYear() - dob.getFullYear();
91
+ const beforeBirthdayThisYear = now.getMonth() < dob.getMonth() ||
92
+ (now.getMonth() === dob.getMonth() && now.getDate() < dob.getDate());
93
+ if (beforeBirthdayThisYear)
94
+ years -= 1;
95
+ return years >= 18;
96
+ }
97
+ async function fetchContactPoints(entity, id) {
98
+ const entityType = entity === "person" ? "person" : "organization";
99
+ try {
100
+ const res = await fetch(`/v1/identity/entities/${entityType}/${encodeURIComponent(id)}/contact-points`, { headers: { accept: "application/json" } });
101
+ if (!res.ok)
102
+ return { email: null, phone: null };
103
+ const json = (await res.json());
104
+ const list = json.data ?? [];
105
+ return {
106
+ email: pickContactPoint(list, "email"),
107
+ phone: pickContactPoint(list, "phone"),
108
+ };
109
+ }
110
+ catch {
111
+ return { email: null, phone: null };
112
+ }
113
+ }
114
+ function pickContactPoint(list, kind) {
115
+ const matches = list.filter((contactPoint) => contactPoint.kind === kind);
116
+ if (matches.length === 0)
117
+ return null;
118
+ const billing = matches.find((contactPoint) => contactPoint.label === "billing");
119
+ if (billing)
120
+ return billing.value;
121
+ const primary = matches.find((contactPoint) => contactPoint.isPrimary);
122
+ if (primary)
123
+ return primary.value;
124
+ return matches[0]?.value ?? null;
125
+ }
126
+ async function fetchPreferredAddress(entity, id) {
127
+ const path = entity === "person" ? "people" : "organizations";
128
+ try {
129
+ const res = await fetch(`/v1/crm/${path}/${encodeURIComponent(id)}/addresses`, {
130
+ headers: { accept: "application/json" },
131
+ });
132
+ if (!res.ok)
133
+ return null;
134
+ const json = (await res.json());
135
+ const list = json.data ?? [];
136
+ if (list.length === 0)
137
+ return null;
138
+ const billing = list.find((address) => address.label === "billing");
139
+ if (billing)
140
+ return billing;
141
+ const primary = list.find((address) => address.label === "primary") ??
142
+ list.find((address) => address.isPrimary);
143
+ return primary ?? list[0] ?? null;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
@@ -0,0 +1,25 @@
1
+ import type { FlightBookRequest, FlightOrder, PassengerCounts } from "@voyantjs/flights/contract/types";
2
+ import type { BillingValue } from "./flight-billing-step.js";
3
+ import { type FlightBookingShellProps } from "./flight-booking-shell.js";
4
+ import type { PaymentStepCapabilities } from "./flight-payment-step.js";
5
+ export interface FlightBookingPageProps {
6
+ outboundOfferId: string;
7
+ returnOfferId?: string;
8
+ passengers: PassengerCounts;
9
+ onBackToSearch: () => void;
10
+ onBook: (request: FlightBookRequest) => Promise<FlightOrder> | FlightOrder;
11
+ onBooked?: (order: FlightOrder) => void;
12
+ onEditOutbound?: () => void;
13
+ onEditReturn?: () => void;
14
+ onSaveBillingDefaults?: (value: BillingValue) => void;
15
+ paymentCapabilities?: PaymentStepCapabilities;
16
+ renderPassengerPicker?: FlightBookingShellProps["renderPassengerPicker"];
17
+ renderBillingPersonPicker?: (apply: (prefill: Partial<BillingValue>) => void, helpers: {
18
+ onPersonSelected: (personId: string | null) => void;
19
+ }) => React.ReactNode;
20
+ renderBillingOrgPicker?: (apply: (prefill: Partial<BillingValue>) => void) => React.ReactNode;
21
+ onAddPassengerContact?: () => void;
22
+ className?: string;
23
+ }
24
+ export declare function FlightBookingPage({ outboundOfferId, returnOfferId, passengers, onBackToSearch, onBook, onBooked, onEditOutbound, onEditReturn, onSaveBillingDefaults, paymentCapabilities, renderPassengerPicker, renderBillingPersonPicker, renderBillingOrgPicker, onAddPassengerContact, className, }: FlightBookingPageProps): import("react/jsx-runtime").JSX.Element;
25
+ //# sourceMappingURL=flight-booking-page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flight-booking-page.d.ts","sourceRoot":"","sources":["../../src/components/flight-booking-page.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,iBAAiB,EAEjB,WAAW,EACX,eAAe,EAChB,MAAM,kCAAkC,CAAA;AAkBzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAE5D,OAAO,EAKL,KAAK,uBAAuB,EAC7B,MAAM,2BAA2B,CAAA;AAClC,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAIvE,MAAM,WAAW,sBAAsB;IACrC,eAAe,EAAE,MAAM,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,eAAe,CAAA;IAC3B,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B,MAAM,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CAAA;IAC1E,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACvC,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B,YAAY,CAAC,EAAE,MAAM,IAAI,CAAA;IACzB,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;IACrD,mBAAmB,CAAC,EAAE,uBAAuB,CAAA;IAC7C,qBAAqB,CAAC,EAAE,uBAAuB,CAAC,uBAAuB,CAAC,CAAA;IACxE,yBAAyB,CAAC,EAAE,CAC1B,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,EAC/C,OAAO,EAAE;QAAE,gBAAgB,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;KAAE,KAC7D,KAAK,CAAC,SAAS,CAAA;IACpB,sBAAsB,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,KAAK,KAAK,CAAC,SAAS,CAAA;IAC7F,qBAAqB,CAAC,EAAE,MAAM,IAAI,CAAA;IAClC,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,eAAe,EACf,aAAa,EACb,UAAU,EACV,cAAc,EACd,MAAM,EACN,QAAQ,EACR,cAAc,EACd,YAAY,EACZ,qBAAqB,EACrB,mBAAmB,EACnB,qBAAqB,EACrB,yBAAyB,EACzB,sBAAsB,EACtB,qBAAqB,EACrB,SAAS,GACV,EAAE,sBAAsB,2CA2MxB"}
@@ -0,0 +1,175 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useQueries, useQueryClient } from "@tanstack/react-query";
4
+ import { flightsQueryKeys, getFlightSeatMapQueryOptions, useAirlines, useAirports, useFlightAncillaries, useFlightOfferPrice, useSavedPaymentMethods, useVoyantFlightsContext, } from "@voyantjs/flights-react";
5
+ import { Button } from "@voyantjs/ui/components/button";
6
+ import { cn } from "@voyantjs/ui/lib/utils";
7
+ import { ChevronLeft, Plane } from "lucide-react";
8
+ import { useEffect, useMemo, useState } from "react";
9
+ import { useFlightsUiMessagesOrDefault } from "../i18n/index.js";
10
+ import { BillingOrgPicker, BillingPersonPicker } from "./billing-pickers.js";
11
+ import { FlightBookingShell, } from "./flight-booking-shell.js";
12
+ import { PassengerContactPicker } from "./passenger-contact-picker.js";
13
+ export function FlightBookingPage({ outboundOfferId, returnOfferId, passengers, onBackToSearch, onBook, onBooked, onEditOutbound, onEditReturn, onSaveBillingDefaults, paymentCapabilities, renderPassengerPicker, renderBillingPersonPicker, renderBillingOrgPicker, onAddPassengerContact, className, }) {
14
+ const messages = useFlightsUiMessagesOrDefault().flightBookingPage;
15
+ const qc = useQueryClient();
16
+ const airlinesQuery = useAirlines();
17
+ const airportsQuery = useAirports({ limit: 200 });
18
+ const carrierName = (code) => airlinesQuery.data?.data.find((airline) => airline.iataCode === code)?.name;
19
+ const airportName = (code) => {
20
+ const airport = airportsQuery.data?.data.find((item) => item.iataCode === code);
21
+ return airport ? `${airport.city} (${airport.iataCode})` : undefined;
22
+ };
23
+ const [outbound, setOutbound] = useState(() => readOfferFromCache(qc, outboundOfferId));
24
+ const [returnLeg, setReturnLeg] = useState(() => returnOfferId ? readOfferFromCache(qc, returnOfferId) : null);
25
+ const [livePriceError, setLivePriceError] = useState(null);
26
+ const [pricedReady, setPricedReady] = useState(false);
27
+ const [selectedPersonId, setSelectedPersonId] = useState(null);
28
+ const priceMutation = useFlightOfferPrice();
29
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-price once on mount per offer pair
30
+ useEffect(() => {
31
+ let cancelled = false;
32
+ const repriceLeg = async (offer, setter) => {
33
+ if (!offer)
34
+ return null;
35
+ try {
36
+ const result = await priceMutation.mutateAsync({ offerId: offer.offerId, offer });
37
+ if (cancelled)
38
+ return null;
39
+ if (!result.valid)
40
+ return result.invalidReason ?? "This offer is no longer available.";
41
+ setter(result.offer);
42
+ return null;
43
+ }
44
+ catch (err) {
45
+ return err instanceof Error ? err.message : String(err);
46
+ }
47
+ };
48
+ Promise.all([repriceLeg(outbound, setOutbound), repriceLeg(returnLeg, setReturnLeg)]).then(([err1, err2]) => {
49
+ if (cancelled)
50
+ return;
51
+ const err = err1 ?? err2;
52
+ if (err)
53
+ setLivePriceError(err);
54
+ else
55
+ setPricedReady(true);
56
+ });
57
+ return () => {
58
+ cancelled = true;
59
+ };
60
+ }, [outboundOfferId, returnOfferId]);
61
+ const outboundAncillaries = useFlightAncillaries(outbound ? { offerId: outbound.offerId, offer: outbound } : null, { enabled: pricedReady && outbound != null });
62
+ const returnAncillaries = useFlightAncillaries(returnLeg ? { offerId: returnLeg.offerId, offer: returnLeg } : null, { enabled: pricedReady && returnLeg != null });
63
+ const ancillaries = {
64
+ outboundCatalog: outboundAncillaries.data?.catalog ?? null,
65
+ returnCatalog: returnAncillaries.data?.catalog ?? null,
66
+ loading: outboundAncillaries.isLoading || (returnLeg != null && returnAncillaries.isLoading),
67
+ };
68
+ const seatMaps = useSeatMapFetcher({ outbound, returnLeg, enabled: pricedReady });
69
+ const savedMethodsQuery = useSavedPaymentMethods(selectedPersonId, {
70
+ enabled: !!selectedPersonId,
71
+ });
72
+ const savedPaymentMethods = {
73
+ methods: (savedMethodsQuery.data?.data ?? []).map((method) => ({
74
+ id: method.id,
75
+ label: [brandHumanLabel(method.brand), method.last4 ? `....${method.last4}` : null]
76
+ .filter(Boolean)
77
+ .join(" "),
78
+ provider: null,
79
+ instrumentType: method.brand === "bank_transfer" ? "bank_account" : "credit_card",
80
+ status: "active",
81
+ brand: method.brand,
82
+ last4: method.last4,
83
+ expiryMonth: method.expMonth ?? null,
84
+ expiryYear: method.expYear ?? null,
85
+ isDefault: method.isDefault,
86
+ })),
87
+ loading: savedMethodsQuery.isLoading,
88
+ };
89
+ const documentsRequired = useMemo(() => detectInternational(outbound) || detectInternational(returnLeg), [outbound, returnLeg]);
90
+ const selection = useMemo(() => {
91
+ if (!outbound)
92
+ return null;
93
+ if (returnOfferId && !returnLeg)
94
+ return null;
95
+ return returnLeg ? { outbound, return: returnLeg } : { outbound };
96
+ }, [outbound, returnLeg, returnOfferId]);
97
+ if (!selection) {
98
+ return (_jsx("div", { className: cn("mx-auto w-full max-w-2xl px-6 py-10", className), children: _jsxs("div", { className: "rounded-xl border border-dashed bg-card p-8 text-center", children: [_jsx(Plane, { className: "mx-auto mb-3 h-8 w-8 text-muted-foreground" }), _jsx("h2", { className: "font-medium text-base", children: messages.offerNotInSessionTitle }), _jsx("p", { className: "mx-auto mt-2 max-w-md text-muted-foreground text-sm", children: messages.offerNotInSessionDescription }), _jsxs(Button, { className: "mt-4", onClick: onBackToSearch, children: [_jsx(ChevronLeft, { className: "mr-1 h-4 w-4" }), messages.backToFlightSearch] })] }) }));
99
+ }
100
+ if (livePriceError) {
101
+ return (_jsx("div", { className: cn("mx-auto w-full max-w-2xl px-6 py-10", className), children: _jsxs("div", { className: "rounded-xl border border-destructive/40 bg-destructive/5 p-6 text-center text-destructive text-sm", children: [_jsx("p", { className: "font-medium", children: livePriceError }), _jsxs(Button, { variant: "outline", className: "mt-4", onClick: onBackToSearch, children: [_jsx(ChevronLeft, { className: "mr-1 h-4 w-4" }), messages.backToFlightSearch] })] }) }));
102
+ }
103
+ const defaultPassengerPicker = (_slot, onPicked) => (_jsx(PassengerContactPicker, { onPick: onPicked, onAddContact: onAddPassengerContact, onPersonSelected: setSelectedPersonId }));
104
+ return (_jsxs("div", { className: cn("mx-auto flex w-full max-w-screen-2xl flex-col gap-6 px-6 py-6 lg:px-8", className), children: [_jsxs("header", { className: "flex items-center justify-between gap-4", children: [_jsxs("div", { children: [_jsx("h1", { className: "font-semibold text-2xl", children: messages.title }), _jsx("p", { className: "text-muted-foreground text-sm", children: selection.return ? messages.descriptionTrip : messages.descriptionOffer })] }), _jsxs(Button, { variant: "ghost", onClick: onBackToSearch, children: [_jsx(ChevronLeft, { className: "mr-1 h-4 w-4" }), messages.backToResults] })] }), _jsx(FlightBookingShell, { selection: selection, passengers: passengers, carrierName: carrierName, airportName: airportName, ancillaries: ancillaries, seatMaps: seatMaps, savedPaymentMethods: savedPaymentMethods, paymentCapabilities: paymentCapabilities, documentsRequired: documentsRequired, renderPassengerPicker: renderPassengerPicker ?? defaultPassengerPicker, renderBillingPersonPicker: (apply) => renderBillingPersonPicker ? (renderBillingPersonPicker(apply, { onPersonSelected: setSelectedPersonId })) : (_jsx(BillingPersonPicker, { apply: apply, onPersonSelected: setSelectedPersonId })), renderBillingOrgPicker: (apply) => renderBillingOrgPicker ? (renderBillingOrgPicker(apply)) : (_jsx(BillingOrgPicker, { apply: apply })), onSaveBillingDefaults: onSaveBillingDefaults, onCancel: onBackToSearch, onEditOutbound: onEditOutbound ?? onBackToSearch, onEditReturn: onEditReturn ?? onBackToSearch, onBook: onBook, onBooked: onBooked })] }));
105
+ }
106
+ function brandHumanLabel(brand) {
107
+ switch (brand) {
108
+ case "visa":
109
+ return "Visa";
110
+ case "mastercard":
111
+ return "Mastercard";
112
+ case "amex":
113
+ return "Amex";
114
+ case "revolut":
115
+ return "Revolut Pay";
116
+ case "bank_transfer":
117
+ return "Bank transfer";
118
+ default:
119
+ return brand;
120
+ }
121
+ }
122
+ function readOfferFromCache(qc, offerId) {
123
+ const cached = qc.getQueryData(flightsQueryKeys.offerDetail(offerId));
124
+ return cached?.offer ?? null;
125
+ }
126
+ function detectInternational(offer) {
127
+ if (!offer)
128
+ return false;
129
+ const first = offer.itineraries[0]?.segments[0];
130
+ const lastItinerary = offer.itineraries[offer.itineraries.length - 1];
131
+ const last = lastItinerary?.segments[lastItinerary.segments.length - 1];
132
+ if (!first || !last)
133
+ return false;
134
+ return first.departure.iataCode.slice(0, 1) !== last.arrival.iataCode.slice(0, 1);
135
+ }
136
+ function useSeatMapFetcher({ outbound, returnLeg, enabled, }) {
137
+ const client = useVoyantFlightsContext();
138
+ const segmentInputs = useMemo(() => {
139
+ const list = [];
140
+ const addFrom = (offer) => {
141
+ if (!offer)
142
+ return;
143
+ for (const itinerary of offer.itineraries) {
144
+ for (const segment of itinerary.segments) {
145
+ list.push({ offerId: offer.offerId, segmentId: segment.segmentId, offer });
146
+ }
147
+ }
148
+ };
149
+ addFrom(outbound);
150
+ addFrom(returnLeg);
151
+ return list;
152
+ }, [outbound, returnLeg]);
153
+ const results = useQueries({
154
+ queries: segmentInputs.map((input) => ({
155
+ ...getFlightSeatMapQueryOptions(client, input),
156
+ enabled,
157
+ staleTime: 5 * 60_000,
158
+ })),
159
+ });
160
+ const slotsBySegment = useMemo(() => {
161
+ const map = new Map();
162
+ segmentInputs.forEach((input, index) => {
163
+ const result = results[index];
164
+ map.set(input.segmentId, {
165
+ seatMap: result?.data?.seatMap ?? null,
166
+ loading: result?.isLoading,
167
+ error: result?.error instanceof Error ? result.error.message : null,
168
+ });
169
+ });
170
+ return map;
171
+ }, [segmentInputs, results]);
172
+ return useMemo(() => ({
173
+ getSeatMap: ({ segmentId }) => slotsBySegment.get(segmentId) ?? { seatMap: null, error: "Segment not found" },
174
+ }), [slotsBySegment]);
175
+ }
@@ -0,0 +1,39 @@
1
+ import type { CabinClass, PassengerCounts } from "@voyantjs/flights/contract/types";
2
+ import { type TripType } from "./flight-search-form.js";
3
+ import { type PopularRoute } from "./popular-routes.js";
4
+ export interface FlightsPageSearchParams {
5
+ tripType?: TripType;
6
+ from?: string;
7
+ to?: string;
8
+ depart?: string;
9
+ ret?: string;
10
+ leg?: "outbound" | "return";
11
+ outboundOfferId?: string;
12
+ returnOfferId?: string;
13
+ pax_a?: number;
14
+ pax_c?: number;
15
+ pax_i?: number;
16
+ cabin?: CabinClass;
17
+ carriers?: string[];
18
+ maxStops?: number;
19
+ maxPrice?: number;
20
+ page?: number;
21
+ }
22
+ export interface FlightsPageSearchChangeOptions {
23
+ replace?: boolean;
24
+ }
25
+ export interface FlightBookingNavigationTarget {
26
+ outboundOfferId: string;
27
+ returnOfferId?: string;
28
+ passengers: PassengerCounts;
29
+ cabin: CabinClass;
30
+ }
31
+ export interface FlightsPageProps {
32
+ search: FlightsPageSearchParams;
33
+ onSearchChange: (next: FlightsPageSearchParams, options?: FlightsPageSearchChangeOptions) => void;
34
+ onBookOffer: (target: FlightBookingNavigationTarget) => void;
35
+ routes?: PopularRoute[];
36
+ className?: string;
37
+ }
38
+ export declare function FlightsPage({ search, onSearchChange, onBookOffer, routes, className, }: FlightsPageProps): import("react/jsx-runtime").JSX.Element;
39
+ //# sourceMappingURL=flights-page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flights-page.d.ts","sourceRoot":"","sources":["../../src/components/flights-page.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,UAAU,EAGV,eAAe,EAChB,MAAM,kCAAkC,CAAA;AA8BzC,OAAO,EAAoB,KAAK,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AACzE,OAAO,EAA0B,KAAK,YAAY,EAAiB,MAAM,qBAAqB,CAAA;AAM9F,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,EAAE,QAAQ,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,UAAU,GAAG,QAAQ,CAAA;IAC3B,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,UAAU,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,8BAA8B;IAC7C,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,6BAA6B;IAC5C,eAAe,EAAE,MAAM,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,eAAe,CAAA;IAC3B,KAAK,EAAE,UAAU,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,uBAAuB,CAAA;IAC/B,cAAc,EAAE,CAAC,IAAI,EAAE,uBAAuB,EAAE,OAAO,CAAC,EAAE,8BAA8B,KAAK,IAAI,CAAA;IACjG,WAAW,EAAE,CAAC,MAAM,EAAE,6BAA6B,KAAK,IAAI,CAAA;IAC5D,MAAM,CAAC,EAAE,YAAY,EAAE,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,cAAc,EACd,WAAW,EACX,MAA+B,EAC/B,SAAS,GACV,EAAE,gBAAgB,2CAyYlB"}