@voyantjs/flights-ui 0.30.6 → 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 +66 -0
- package/dist/components/billing-pickers.d.ts +19 -0
- package/dist/components/billing-pickers.d.ts.map +1 -0
- package/dist/components/billing-pickers.js +148 -0
- package/dist/components/flight-booking-page.d.ts +25 -0
- package/dist/components/flight-booking-page.d.ts.map +1 -0
- package/dist/components/flight-booking-page.js +175 -0
- package/dist/components/flights-page.d.ts +39 -0
- package/dist/components/flights-page.d.ts.map +1 -0
- package/dist/components/flights-page.js +318 -0
- package/dist/components/passenger-contact-picker.d.ts +16 -0
- package/dist/components/passenger-contact-picker.d.ts.map +1 -0
- package/dist/components/passenger-contact-picker.js +45 -0
- package/dist/i18n/en.d.ts +61 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +60 -0
- package/dist/i18n/index.d.ts +5 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +3 -0
- package/dist/i18n/messages.d.ts +61 -0
- package/dist/i18n/messages.d.ts.map +1 -0
- package/dist/i18n/messages.js +1 -0
- package/dist/i18n/provider.d.ts +144 -0
- package/dist/i18n/provider.d.ts.map +1 -0
- package/dist/i18n/provider.js +44 -0
- package/dist/i18n/ro.d.ts +61 -0
- package/dist/i18n/ro.d.ts.map +1 -0
- package/dist/i18n/ro.js +60 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/package.json +32 -11
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"}
|