@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.
- package/dist/components/airline-logo.d.ts +19 -0
- package/dist/components/airline-logo.d.ts.map +1 -0
- package/dist/components/airline-logo.js +18 -0
- package/dist/components/airport-combobox.d.ts +20 -0
- package/dist/components/airport-combobox.d.ts.map +1 -0
- package/dist/components/airport-combobox.js +29 -0
- package/dist/components/flight-baggage-step.d.ts +32 -0
- package/dist/components/flight-baggage-step.d.ts.map +1 -0
- package/dist/components/flight-baggage-step.js +106 -0
- package/dist/components/flight-billing-step.d.ts +69 -0
- package/dist/components/flight-billing-step.d.ts.map +1 -0
- package/dist/components/flight-billing-step.js +111 -0
- package/dist/components/flight-booking-journey.d.ts +31 -0
- package/dist/components/flight-booking-journey.d.ts.map +1 -0
- package/dist/components/flight-booking-journey.js +114 -0
- package/dist/components/flight-booking-ledger.d.ts +53 -0
- package/dist/components/flight-booking-ledger.d.ts.map +1 -0
- package/dist/components/flight-booking-ledger.js +94 -0
- package/dist/components/flight-booking-shell.d.ts +92 -0
- package/dist/components/flight-booking-shell.d.ts.map +1 -0
- package/dist/components/flight-booking-shell.js +486 -0
- package/dist/components/flight-contact-form.d.ts +16 -0
- package/dist/components/flight-contact-form.d.ts.map +1 -0
- package/dist/components/flight-contact-form.js +21 -0
- package/dist/components/flight-fare-upsell-step.d.ts +26 -0
- package/dist/components/flight-fare-upsell-step.d.ts.map +1 -0
- package/dist/components/flight-fare-upsell-step.js +141 -0
- package/dist/components/flight-filters-bar.d.ts +19 -0
- package/dist/components/flight-filters-bar.d.ts.map +1 -0
- package/dist/components/flight-filters-bar.js +90 -0
- package/dist/components/flight-itinerary.d.ts +28 -0
- package/dist/components/flight-itinerary.d.ts.map +1 -0
- package/dist/components/flight-itinerary.js +90 -0
- package/dist/components/flight-offer-detail.d.ts +21 -0
- package/dist/components/flight-offer-detail.d.ts.map +1 -0
- package/dist/components/flight-offer-detail.js +61 -0
- package/dist/components/flight-offer-row.d.ts +25 -0
- package/dist/components/flight-offer-row.d.ts.map +1 -0
- package/dist/components/flight-offer-row.js +74 -0
- package/dist/components/flight-order-confirmation.d.ts +13 -0
- package/dist/components/flight-order-confirmation.d.ts.map +1 -0
- package/dist/components/flight-order-confirmation.js +50 -0
- package/dist/components/flight-passenger-form.d.ts +49 -0
- package/dist/components/flight-passenger-form.d.ts.map +1 -0
- package/dist/components/flight-passenger-form.js +155 -0
- package/dist/components/flight-payment-selector.d.ts +13 -0
- package/dist/components/flight-payment-selector.d.ts.map +1 -0
- package/dist/components/flight-payment-selector.js +36 -0
- package/dist/components/flight-payment-step.d.ts +32 -0
- package/dist/components/flight-payment-step.d.ts.map +1 -0
- package/dist/components/flight-payment-step.js +82 -0
- package/dist/components/flight-search-form.d.ts +14 -0
- package/dist/components/flight-search-form.d.ts.map +1 -0
- package/dist/components/flight-search-form.js +56 -0
- package/dist/components/flight-seat-map.d.ts +32 -0
- package/dist/components/flight-seat-map.d.ts.map +1 -0
- package/dist/components/flight-seat-map.js +96 -0
- package/dist/components/flight-seats-step.d.ts +40 -0
- package/dist/components/flight-seats-step.d.ts.map +1 -0
- package/dist/components/flight-seats-step.js +211 -0
- package/dist/components/flight-services-step.d.ts +27 -0
- package/dist/components/flight-services-step.d.ts.map +1 -0
- package/dist/components/flight-services-step.js +110 -0
- package/dist/components/pax-cabin-popover.d.ts +18 -0
- package/dist/components/pax-cabin-popover.d.ts.map +1 -0
- package/dist/components/pax-cabin-popover.js +38 -0
- package/dist/components/popular-routes.d.ts +47 -0
- package/dist/components/popular-routes.d.ts.map +1 -0
- package/dist/components/popular-routes.js +126 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/package.json +77 -0
- package/src/styles.css +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Badge } from "@voyantjs/ui/components/badge";
|
|
4
|
+
import { Button } from "@voyantjs/ui/components/button";
|
|
5
|
+
import { Separator } from "@voyantjs/ui/components/separator";
|
|
6
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
7
|
+
import { CheckCircle2, Clock, Mail, Phone, Ticket, XCircle } from "lucide-react";
|
|
8
|
+
import { FlightOfferDetail } from "./flight-offer-detail";
|
|
9
|
+
const STATUS_VARIANTS = {
|
|
10
|
+
pending: { label: "Pending", tone: "pending", icon: _jsx(Clock, { className: "h-4 w-4" }) },
|
|
11
|
+
confirmed: { label: "Confirmed", tone: "ok", icon: _jsx(CheckCircle2, { className: "h-4 w-4" }) },
|
|
12
|
+
ticketed: { label: "Ticketed", tone: "ok", icon: _jsx(Ticket, { className: "h-4 w-4" }) },
|
|
13
|
+
cancelled: { label: "Cancelled", tone: "bad", icon: _jsx(XCircle, { className: "h-4 w-4" }) },
|
|
14
|
+
failed: { label: "Failed", tone: "bad", icon: _jsx(XCircle, { className: "h-4 w-4" }) },
|
|
15
|
+
};
|
|
16
|
+
export function FlightOrderConfirmation({ order, onCancel, cancelLoading, carrierName, airportName, aircraftName, }) {
|
|
17
|
+
const status = STATUS_VARIANTS[order.status];
|
|
18
|
+
const isCancellable = onCancel != null && (order.status === "confirmed" || order.status === "ticketed");
|
|
19
|
+
return (_jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("div", { className: "rounded-xl border bg-card p-5 shadow-sm", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: "Booking confirmed" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("h2", { className: "font-mono text-2xl font-semibold tracking-wider", children: order.pnr ?? order.orderId }), _jsxs(Badge, { variant: status.tone === "ok" ? "default" : "secondary", className: cn("gap-1.5", status.tone === "bad" && "bg-destructive/10 text-destructive"), children: [status.icon, status.label] })] }), _jsx("span", { className: "font-mono text-[11px] text-muted-foreground", children: order.orderId })] }), _jsxs("div", { className: "text-right", children: [_jsx("div", { className: "text-2xl font-semibold tabular-nums", children: formatMoney(order.totalPrice.amount, order.totalPrice.currency) }), _jsx("div", { className: "text-xs text-muted-foreground", children: "total" })] })] }), order.paymentDeadline && order.status === "confirmed" && (_jsxs("div", { className: "mt-4 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900", children: [_jsx(Clock, { className: "mt-0.5 h-3.5 w-3.5 shrink-0" }), _jsxs("div", { children: ["Tickets must be issued before ", _jsx("strong", { children: formatDateTime(order.paymentDeadline) }), " ", "or the seats will be released."] })] }))] }), _jsx(Section, { title: "Passengers", children: _jsx("div", { className: "flex flex-col gap-2", children: order.passengers.map((p) => (_jsxs("div", { className: "flex items-center justify-between rounded-md border bg-card px-3 py-2.5 text-sm", children: [_jsxs("div", { className: "flex flex-col leading-tight", children: [_jsx("span", { className: "font-medium", children: [p.firstName, p.middleName, p.lastName].filter(Boolean).join(" ") }), _jsxs("span", { className: "text-xs text-muted-foreground capitalize", children: [p.type, p.dateOfBirth && ` · DOB ${p.dateOfBirth}`] })] }), order.tickets && (_jsx(TicketChip, { number: order.tickets.find((t) => t.passengerId === p.passengerId)?.ticketNumber }))] }, p.passengerId))) }) }), (order.contact?.email || order.contact?.phone) && (_jsx(Section, { title: "Contact", children: _jsxs("div", { className: "flex flex-wrap gap-4 text-sm", children: [order.contact.email && (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx(Mail, { className: "h-3.5 w-3.5 text-muted-foreground" }), order.contact.email] })), order.contact.phone && (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx(Phone, { className: "h-3.5 w-3.5 text-muted-foreground" }), order.contact.phone] }))] }) })), _jsx(Section, { title: "Itinerary", children: _jsx(FlightOfferDetail, { offer: order.offer, carrierName: carrierName, airportName: airportName, aircraftName: aircraftName }) }), isCancellable && (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { variant: "destructive", onClick: () => onCancel(order), disabled: cancelLoading, children: cancelLoading ? "Cancelling…" : "Cancel booking" }) })] }))] }));
|
|
20
|
+
}
|
|
21
|
+
function Section({ title, children }) {
|
|
22
|
+
return (_jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h3", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: title }), children] }));
|
|
23
|
+
}
|
|
24
|
+
function TicketChip({ number }) {
|
|
25
|
+
if (!number) {
|
|
26
|
+
return _jsx("span", { className: "text-xs text-muted-foreground", children: "\u2014" });
|
|
27
|
+
}
|
|
28
|
+
return _jsx("code", { className: "rounded bg-muted px-2 py-1 font-mono text-[11px]", children: number });
|
|
29
|
+
}
|
|
30
|
+
function formatMoney(amount, currency) {
|
|
31
|
+
const n = Number(amount);
|
|
32
|
+
if (!Number.isFinite(n))
|
|
33
|
+
return `${amount} ${currency}`;
|
|
34
|
+
return new Intl.NumberFormat(undefined, {
|
|
35
|
+
style: "currency",
|
|
36
|
+
currency,
|
|
37
|
+
maximumFractionDigits: 0,
|
|
38
|
+
}).format(n);
|
|
39
|
+
}
|
|
40
|
+
function formatDateTime(iso) {
|
|
41
|
+
const d = new Date(iso);
|
|
42
|
+
if (Number.isNaN(d.getTime()))
|
|
43
|
+
return iso;
|
|
44
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
45
|
+
day: "numeric",
|
|
46
|
+
month: "short",
|
|
47
|
+
hour: "2-digit",
|
|
48
|
+
minute: "2-digit",
|
|
49
|
+
}).format(d);
|
|
50
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { FlightPassenger, PassengerCounts, PassengerType } from "@voyantjs/flights/contract/types";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
/** Subset of FlightPassenger that a picker can pre-fill into a card. */
|
|
4
|
+
export type PassengerPrefill = Partial<Pick<FlightPassenger, "firstName" | "middleName" | "lastName" | "dateOfBirth" | "gender" | "email" | "phone">>;
|
|
5
|
+
export interface FlightPassengerFormProps {
|
|
6
|
+
/** How many passengers of each type the booking is for. */
|
|
7
|
+
counts: PassengerCounts;
|
|
8
|
+
/** Current passenger details (one entry per pax slot). */
|
|
9
|
+
value: FlightPassenger[];
|
|
10
|
+
onChange: (next: FlightPassenger[]) => void;
|
|
11
|
+
/**
|
|
12
|
+
* Surface a banner above the cards prompting the operator to add travel
|
|
13
|
+
* documents — typically true when the route looks international. Documents
|
|
14
|
+
* remain optional (skip-to-check-in is one click), but the prompt nudges
|
|
15
|
+
* the operator to fill them up-front.
|
|
16
|
+
*/
|
|
17
|
+
documentsRequired?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Optional render slot for a person picker. Rendered next to each
|
|
20
|
+
* passenger card title — typically a "Pick from contacts" button. Calling
|
|
21
|
+
* `onPicked` with prefill data merges it into the card without
|
|
22
|
+
* overwriting fields the picker doesn't carry (e.g. CRM has no DOB).
|
|
23
|
+
*/
|
|
24
|
+
renderPicker?: (slot: {
|
|
25
|
+
passengerId: string;
|
|
26
|
+
type: PassengerType;
|
|
27
|
+
}, onPicked: (prefill: PassengerPrefill) => void) => ReactNode;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Editable list of passenger forms — one card per pax slot derived from
|
|
31
|
+
* `counts`. Synthesizes stable `passengerId`s ("pax_adult_1", "pax_child_1",
|
|
32
|
+
* etc.) so the booking adapter can link tickets to passengers.
|
|
33
|
+
*
|
|
34
|
+
* Required fields per pax: type (set), firstName, lastName, dateOfBirth.
|
|
35
|
+
* Gender + email/phone are optional. A travel-document subsection lives on
|
|
36
|
+
* each card — collapsed by default ("Add at check-in instead"), expanded
|
|
37
|
+
* when the operator opts to capture passport / national-id up front. When
|
|
38
|
+
* filled, the document is written to `value.documents[0]` so it ships
|
|
39
|
+
* straight through `bookFlight`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function FlightPassengerForm({ counts, value, onChange, documentsRequired, renderPicker, }: FlightPassengerFormProps): import("react/jsx-runtime").JSX.Element;
|
|
42
|
+
/**
|
|
43
|
+
* Validate a list of passengers — returns the list of errors keyed by
|
|
44
|
+
* passenger id, or an empty object when everything's filled. Used by the
|
|
45
|
+
* journey to gate the Continue button. Documents are optional, but when
|
|
46
|
+
* the operator opted to add one its required fields must be filled.
|
|
47
|
+
*/
|
|
48
|
+
export declare function validatePassengers(value: FlightPassenger[]): Record<string, string>;
|
|
49
|
+
//# sourceMappingURL=flight-passenger-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-passenger-form.d.ts","sourceRoot":"","sources":["../../src/components/flight-passenger-form.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EACf,aAAa,EAEd,MAAM,kCAAkC,CAAA;AAczC,OAAO,EAAE,KAAK,SAAS,EAAsB,MAAM,OAAO,CAAA;AAE1D,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,OAAO,CACpC,IAAI,CACF,eAAe,EACf,WAAW,GAAG,YAAY,GAAG,UAAU,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CACvF,CACF,CAAA;AAcD,MAAM,WAAW,wBAAwB;IACvC,2DAA2D;IAC3D,MAAM,EAAE,eAAe,CAAA;IACvB,0DAA0D;IAC1D,KAAK,EAAE,eAAe,EAAE,CAAA;IACxB,QAAQ,EAAE,CAAC,IAAI,EAAE,eAAe,EAAE,KAAK,IAAI,CAAA;IAC3C;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CACb,IAAI,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,aAAa,CAAA;KAAE,EAClD,QAAQ,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,KAC1C,SAAS,CAAA;CACf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,KAAK,EACL,QAAQ,EACR,iBAAiB,EACjB,YAAY,GACb,EAAE,wBAAwB,2CAmD1B;AAkPD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAgBnF"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Checkbox } from "@voyantjs/ui/components/checkbox";
|
|
4
|
+
import { CountryCombobox } from "@voyantjs/ui/components/country-combobox";
|
|
5
|
+
import { DatePicker } from "@voyantjs/ui/components/date-picker";
|
|
6
|
+
import { Input } from "@voyantjs/ui/components/input";
|
|
7
|
+
import { Label } from "@voyantjs/ui/components/label";
|
|
8
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@voyantjs/ui/components/select";
|
|
9
|
+
import { CircleAlert, IdCard } from "lucide-react";
|
|
10
|
+
import { useEffect, useMemo } from "react";
|
|
11
|
+
// Date-of-birth range — anyone born within the last 110 years is plausible;
|
|
12
|
+
// future dates make no sense. Computed once at module load.
|
|
13
|
+
const DOB_END_MONTH = new Date();
|
|
14
|
+
const DOB_START_MONTH = new Date(DOB_END_MONTH.getFullYear() - 110, 0, 1);
|
|
15
|
+
// Travel-document expiry range — passports/national IDs are typically valid
|
|
16
|
+
// 10 years; visas can extend further. Cap at +30 years to keep the year
|
|
17
|
+
// dropdown manageable. Past dates are disabled (an expired doc isn't usable
|
|
18
|
+
// for booking).
|
|
19
|
+
const EXPIRY_START_MONTH = new Date();
|
|
20
|
+
const EXPIRY_END_MONTH = new Date(EXPIRY_START_MONTH.getFullYear() + 30, 11, 31);
|
|
21
|
+
/**
|
|
22
|
+
* Editable list of passenger forms — one card per pax slot derived from
|
|
23
|
+
* `counts`. Synthesizes stable `passengerId`s ("pax_adult_1", "pax_child_1",
|
|
24
|
+
* etc.) so the booking adapter can link tickets to passengers.
|
|
25
|
+
*
|
|
26
|
+
* Required fields per pax: type (set), firstName, lastName, dateOfBirth.
|
|
27
|
+
* Gender + email/phone are optional. A travel-document subsection lives on
|
|
28
|
+
* each card — collapsed by default ("Add at check-in instead"), expanded
|
|
29
|
+
* when the operator opts to capture passport / national-id up front. When
|
|
30
|
+
* filled, the document is written to `value.documents[0]` so it ships
|
|
31
|
+
* straight through `bookFlight`.
|
|
32
|
+
*/
|
|
33
|
+
export function FlightPassengerForm({ counts, value, onChange, documentsRequired, renderPicker, }) {
|
|
34
|
+
// Derive the canonical pax slots from `counts`. Passenger ids are stable
|
|
35
|
+
// by position so re-ordering is safe.
|
|
36
|
+
const slots = useMemo(() => buildSlots(counts), [counts]);
|
|
37
|
+
// Materialize missing rows so the UI always has one value per slot.
|
|
38
|
+
// (Keeps existing values when the user navigates back to this step.)
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (slots.length === value.length)
|
|
41
|
+
return;
|
|
42
|
+
const next = slots.map((slot) => {
|
|
43
|
+
const existing = value.find((p) => p.passengerId === slot.passengerId);
|
|
44
|
+
return existing ?? blankPassenger(slot.passengerId, slot.type);
|
|
45
|
+
});
|
|
46
|
+
onChange(next);
|
|
47
|
+
// We intentionally only re-sync when slot identities change (counts edit).
|
|
48
|
+
}, [slots, value.length, value.find, onChange]);
|
|
49
|
+
const set = (passengerId, patch) => {
|
|
50
|
+
onChange(value.map((p) => (p.passengerId === passengerId ? { ...p, ...patch } : p)));
|
|
51
|
+
};
|
|
52
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [documentsRequired && (_jsxs("div", { className: "flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-3 text-amber-700 text-sm", children: [_jsx(CircleAlert, { className: "mt-0.5 h-4 w-4 shrink-0" }), _jsx("span", { children: "This route looks international \u2014 adding travel documents now speeds up online check-in and avoids airport-counter fees. You can still skip and add them later." })] })), slots.map((slot, i) => {
|
|
53
|
+
const pax = value.find((p) => p.passengerId === slot.passengerId) ??
|
|
54
|
+
blankPassenger(slot.passengerId, slot.type);
|
|
55
|
+
const idx = sameTypeIndex(slots, slot, i) + 1;
|
|
56
|
+
return (_jsx(PassengerCard, { label: `${labelFor(slot.type)} ${idx}`, value: pax, onChange: (patch) => set(slot.passengerId, patch), picker: renderPicker?.(slot, (prefill) => set(slot.passengerId, stripUndefined(prefill))) }, slot.passengerId));
|
|
57
|
+
})] }));
|
|
58
|
+
}
|
|
59
|
+
function stripUndefined(obj) {
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
62
|
+
if (v !== undefined && v !== null)
|
|
63
|
+
out[k] = v;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function PassengerCard({ label, value, onChange, picker, }) {
|
|
68
|
+
const doc = value.documents?.[0] ?? null;
|
|
69
|
+
const setDoc = (next) => {
|
|
70
|
+
onChange({ documents: next ? [next] : undefined });
|
|
71
|
+
};
|
|
72
|
+
const updateDoc = (patch) => {
|
|
73
|
+
setDoc({ ...(doc ?? emptyDoc()), ...patch });
|
|
74
|
+
};
|
|
75
|
+
return (_jsxs("div", { className: "rounded-lg border bg-card p-4 shadow-sm", children: [_jsxs("div", { className: "mb-3 flex items-center justify-between gap-2", children: [_jsx("h3", { className: "font-medium text-sm", children: label }), picker] }), _jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-3", children: [_jsx(Field, { label: "First name", required: true, children: _jsx(Input, { value: value.firstName, onChange: (e) => onChange({ firstName: e.target.value }), placeholder: "As on passport" }) }), _jsx(Field, { label: "Middle name", children: _jsx(Input, { value: value.middleName ?? "", onChange: (e) => onChange({ middleName: e.target.value }), placeholder: "Optional" }) }), _jsx(Field, { label: "Last name", required: true, children: _jsx(Input, { value: value.lastName, onChange: (e) => onChange({ lastName: e.target.value }), placeholder: "As on passport" }) }), _jsx(Field, { label: "Date of birth", required: true, children: _jsx(DatePicker, { value: value.dateOfBirth || null, onChange: (v) => onChange({ dateOfBirth: v ?? "" }), placeholder: "Select date", className: "w-full", captionLayout: "dropdown", startMonth: DOB_START_MONTH, endMonth: DOB_END_MONTH, disabled: { after: DOB_END_MONTH } }) }), _jsx(Field, { label: "Gender", children: _jsxs(Select, { value: value.gender ?? "", onValueChange: (v) => {
|
|
76
|
+
if (v)
|
|
77
|
+
onChange({ gender: v });
|
|
78
|
+
}, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: "Select" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "M", children: "Male" }), _jsx(SelectItem, { value: "F", children: "Female" }), _jsx(SelectItem, { value: "X", children: "Unspecified / X" })] })] }) })] }), _jsxs("div", { className: "mt-4 border-t pt-4", children: [_jsxs("div", { className: "mb-3 flex items-center justify-between gap-2", children: [_jsxs("span", { className: "flex items-center gap-1.5 font-medium text-sm", children: [_jsx(IdCard, { className: "h-3.5 w-3.5 text-muted-foreground" }), "Travel document"] }), _jsxs("div", { className: "flex items-center gap-2 text-muted-foreground text-xs", children: [_jsx(Checkbox, { id: `pax-${value.passengerId}-doc-toggle`, checked: doc != null, onCheckedChange: (v) => setDoc(v ? emptyDoc() : null) }), _jsx("label", { htmlFor: `pax-${value.passengerId}-doc-toggle`, className: "cursor-pointer", children: "Add now" })] })] }), !doc && (_jsx("p", { className: "text-muted-foreground text-xs", children: "Skip to add at check-in. Required up-front for most international travel." })), doc && (_jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-3", children: [_jsx(Field, { label: "Document type", required: true, children: _jsxs(Select, { value: doc.type, onValueChange: (v) => {
|
|
79
|
+
if (!v)
|
|
80
|
+
return;
|
|
81
|
+
updateDoc({ type: v });
|
|
82
|
+
}, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "passport", children: "Passport" }), _jsx(SelectItem, { value: "national_id", children: "National ID" }), _jsx(SelectItem, { value: "visa", children: "Visa" })] })] }) }), _jsx(Field, { label: "Document number", required: true, children: _jsx(Input, { value: doc.number, onChange: (e) => updateDoc({ number: e.target.value }), placeholder: "As printed on document", autoCapitalize: "characters" }) }), _jsx(Field, { label: "Country of issue", required: true, children: _jsx(CountryCombobox, { value: doc.countryOfIssue || null, onChange: (code) => updateDoc({ countryOfIssue: code ?? "" }) }) }), _jsx(Field, { label: "Country of nationality", children: _jsx(CountryCombobox, { value: doc.countryOfNationality ?? null, onChange: (code) => updateDoc({ countryOfNationality: code ?? undefined }) }) }), _jsx(Field, { label: "Expiry date", required: true, children: _jsx(DatePicker, { value: doc.expiryDate ?? null, onChange: (v) => updateDoc({ expiryDate: v ?? undefined }), placeholder: "Select date", className: "w-full", captionLayout: "dropdown", startMonth: EXPIRY_START_MONTH, endMonth: EXPIRY_END_MONTH, disabled: { before: EXPIRY_START_MONTH } }) })] }))] })] }));
|
|
83
|
+
}
|
|
84
|
+
function emptyDoc() {
|
|
85
|
+
return {
|
|
86
|
+
type: "passport",
|
|
87
|
+
number: "",
|
|
88
|
+
countryOfIssue: "",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function Field({ label, required, children, }) {
|
|
92
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-[11px] uppercase tracking-wider text-muted-foreground", children: [label, required && _jsx("span", { className: "ml-0.5 text-destructive", children: "*" })] }), children] }));
|
|
93
|
+
}
|
|
94
|
+
function buildSlots(counts) {
|
|
95
|
+
const slots = [];
|
|
96
|
+
for (let i = 1; i <= counts.adults; i++) {
|
|
97
|
+
slots.push({ passengerId: `pax_adult_${i}`, type: "adult" });
|
|
98
|
+
}
|
|
99
|
+
for (let i = 1; i <= (counts.children ?? 0); i++) {
|
|
100
|
+
slots.push({ passengerId: `pax_child_${i}`, type: "child" });
|
|
101
|
+
}
|
|
102
|
+
for (let i = 1; i <= (counts.infants ?? 0); i++) {
|
|
103
|
+
slots.push({ passengerId: `pax_infant_${i}`, type: "infant" });
|
|
104
|
+
}
|
|
105
|
+
return slots;
|
|
106
|
+
}
|
|
107
|
+
function blankPassenger(passengerId, type) {
|
|
108
|
+
return {
|
|
109
|
+
passengerId,
|
|
110
|
+
type,
|
|
111
|
+
firstName: "",
|
|
112
|
+
lastName: "",
|
|
113
|
+
dateOfBirth: "",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function labelFor(type) {
|
|
117
|
+
return type[0]?.toUpperCase() + type.slice(1);
|
|
118
|
+
}
|
|
119
|
+
function sameTypeIndex(slots, slot, idx) {
|
|
120
|
+
let count = 0;
|
|
121
|
+
for (let i = 0; i < idx; i++) {
|
|
122
|
+
if (slots[i]?.type === slot.type)
|
|
123
|
+
count++;
|
|
124
|
+
}
|
|
125
|
+
return count;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Validate a list of passengers — returns the list of errors keyed by
|
|
129
|
+
* passenger id, or an empty object when everything's filled. Used by the
|
|
130
|
+
* journey to gate the Continue button. Documents are optional, but when
|
|
131
|
+
* the operator opted to add one its required fields must be filled.
|
|
132
|
+
*/
|
|
133
|
+
export function validatePassengers(value) {
|
|
134
|
+
const errors = {};
|
|
135
|
+
for (const p of value) {
|
|
136
|
+
if (!p.firstName.trim())
|
|
137
|
+
errors[p.passengerId] = "First name required";
|
|
138
|
+
else if (!p.lastName.trim())
|
|
139
|
+
errors[p.passengerId] = "Last name required";
|
|
140
|
+
else if (!p.dateOfBirth)
|
|
141
|
+
errors[p.passengerId] = "Date of birth required";
|
|
142
|
+
else {
|
|
143
|
+
const doc = p.documents?.[0];
|
|
144
|
+
if (doc) {
|
|
145
|
+
if (!doc.number.trim())
|
|
146
|
+
errors[p.passengerId] = "Document number required";
|
|
147
|
+
else if (!doc.countryOfIssue.trim())
|
|
148
|
+
errors[p.passengerId] = "Document country required";
|
|
149
|
+
else if (!doc.expiryDate)
|
|
150
|
+
errors[p.passengerId] = "Document expiry required";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return errors;
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PaymentIntent } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export interface FlightPaymentSelectorProps {
|
|
3
|
+
value: PaymentIntent;
|
|
4
|
+
onChange: (next: PaymentIntent) => void;
|
|
5
|
+
/**
|
|
6
|
+
* Which intents to show. Defaults to all three. Hide options the
|
|
7
|
+
* configured connector doesn't declare (e.g. drop `hold` when
|
|
8
|
+
* `flight/holds` capability isn't declared).
|
|
9
|
+
*/
|
|
10
|
+
available?: Array<PaymentIntent["type"]>;
|
|
11
|
+
}
|
|
12
|
+
export declare function FlightPaymentSelector({ value, onChange, available }: FlightPaymentSelectorProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
//# sourceMappingURL=flight-payment-selector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-payment-selector.d.ts","sourceRoot":"","sources":["../../src/components/flight-payment-selector.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAA;AAKrE,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,aAAa,CAAA;IACpB,QAAQ,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,CAAA;IACvC;;;;OAIG;IACH,SAAS,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;CACzC;AAqCD,wBAAgB,qBAAqB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,0BAA0B,2CAqD/F"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
4
|
+
import { Banknote, Clock, CreditCard } from "lucide-react";
|
|
5
|
+
const INTENTS = [
|
|
6
|
+
{
|
|
7
|
+
id: "hold",
|
|
8
|
+
title: "Hold seats — pay later",
|
|
9
|
+
description: "Confirms the booking now and locks in the price for the connector's hold window. Tickets issue when payment lands.",
|
|
10
|
+
icon: _jsx(Clock, { className: "h-5 w-5" }),
|
|
11
|
+
build: () => ({ type: "hold" }),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "card",
|
|
15
|
+
title: "Pay by card",
|
|
16
|
+
description: "Tickets issue immediately. Card details handled outside this form by the connector's tokenization flow.",
|
|
17
|
+
icon: _jsx(CreditCard, { className: "h-5 w-5" }),
|
|
18
|
+
build: () => ({ type: "card", token: "demo_card_token" }),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "ticket_on_credit",
|
|
22
|
+
title: "Ticket on agency credit",
|
|
23
|
+
description: "Issue against the operator's IATA office credit line. Settles via BSP in the next reporting cycle.",
|
|
24
|
+
icon: _jsx(Banknote, { className: "h-5 w-5" }),
|
|
25
|
+
build: () => ({ type: "ticket_on_credit" }),
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
export function FlightPaymentSelector({ value, onChange, available }) {
|
|
29
|
+
const visibleIntents = available ? INTENTS.filter((i) => available.includes(i.id)) : INTENTS;
|
|
30
|
+
return (_jsxs("div", { className: "rounded-lg border bg-card p-4 shadow-sm", children: [_jsx("h3", { className: "mb-3 font-medium text-sm", children: "Payment intent" }), _jsx("p", { className: "mb-4 text-xs text-muted-foreground", children: "How the booking should be paid. Hold lets you confirm seats now and ticket later; card / on-credit issue tickets immediately." }), _jsx("div", { role: "radiogroup", className: "flex flex-col gap-2", children: visibleIntents.map((intent) => {
|
|
31
|
+
const isSelected = value.type === intent.id;
|
|
32
|
+
return (_jsxs("button", { type: "button", role: "radio", "aria-checked": isSelected, onClick: () => onChange(intent.build()), className: cn("flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors", isSelected ? "border-primary/40 bg-primary/5" : "border-border hover:bg-accent/30"), children: [_jsx("div", { className: cn("shrink-0 rounded-md border p-2", isSelected
|
|
33
|
+
? "border-primary/30 bg-primary text-primary-foreground"
|
|
34
|
+
: "border-border bg-muted text-muted-foreground"), children: intent.icon }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("span", { className: "text-sm font-medium", children: intent.title }), _jsx("span", { className: "text-xs text-muted-foreground", children: intent.description })] }), _jsx("div", { className: cn("mt-1 h-4 w-4 shrink-0 rounded-full border-2", isSelected ? "border-primary bg-primary" : "border-border"), "aria-hidden": true })] }, intent.id));
|
|
35
|
+
}) })] }));
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type PaymentStepCapabilities, type SavedPaymentAccount } from "@voyantjs/checkout-ui";
|
|
2
|
+
import type { PaymentIntent } from "@voyantjs/flights/contract/types";
|
|
3
|
+
export type { PaymentStepCapabilities, SavedPaymentAccount };
|
|
4
|
+
/** Back-compat alias — older callers used this name; same shape. */
|
|
5
|
+
export type SavedPaymentMethod = SavedPaymentAccount;
|
|
6
|
+
export interface FlightPaymentStepProps {
|
|
7
|
+
/** Flight-contract intent — kept for back-compat with the booking shell. */
|
|
8
|
+
value: PaymentIntent;
|
|
9
|
+
onChange: (next: PaymentIntent) => void;
|
|
10
|
+
/** Saved methods for the picked person — empty array when none on file. */
|
|
11
|
+
savedMethods: SavedPaymentAccount[];
|
|
12
|
+
loadingSavedMethods?: boolean;
|
|
13
|
+
/** Currently selected saved method id (mirror of state held in the parent). */
|
|
14
|
+
selectedSavedId: string | null;
|
|
15
|
+
onSelectSaved: (id: string | null) => void;
|
|
16
|
+
/**
|
|
17
|
+
* What the active processor / template actually offers for immediate
|
|
18
|
+
* charge flows (`chargeSavedCard`, `newCard`). Hold and the
|
|
19
|
+
* "Issue on agency credit" extra are always rendered.
|
|
20
|
+
*
|
|
21
|
+
* See `docs/architecture/payments-architecture.md` §Core Rule 7.
|
|
22
|
+
*/
|
|
23
|
+
capabilities?: PaymentStepCapabilities;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Flight-vertical wrapper around `<PaymentStep>` from `@voyantjs/checkout-ui`.
|
|
27
|
+
* Maps the universal `PaymentChoice` event into the flight contract's
|
|
28
|
+
* `PaymentIntent` shape, and contributes the "Issue ticket on agency
|
|
29
|
+
* credit" extra option (flight-specific).
|
|
30
|
+
*/
|
|
31
|
+
export declare function FlightPaymentStep({ value, onChange, savedMethods, loadingSavedMethods, selectedSavedId, onSelectSaved, capabilities, }: FlightPaymentStepProps): import("react/jsx-runtime").JSX.Element;
|
|
32
|
+
//# sourceMappingURL=flight-payment-step.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-payment-step.d.ts","sourceRoot":"","sources":["../../src/components/flight-payment-step.tsx"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,uBAAuB,EAE5B,KAAK,mBAAmB,EACzB,MAAM,uBAAuB,CAAA;AAC9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAA;AAMrE,YAAY,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,CAAA;AAC5D,oEAAoE;AACpE,MAAM,MAAM,kBAAkB,GAAG,mBAAmB,CAAA;AAEpD,MAAM,WAAW,sBAAsB;IACrC,4EAA4E;IAC5E,KAAK,EAAE,aAAa,CAAA;IACpB,QAAQ,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,CAAA;IACvC,2EAA2E;IAC3E,YAAY,EAAE,mBAAmB,EAAE,CAAA;IACnC,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,+EAA+E;IAC/E,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,aAAa,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IAC1C;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,uBAAuB,CAAA;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,QAAQ,EACR,YAAY,EACZ,mBAAmB,EACnB,eAAe,EACf,aAAa,EACb,YAAY,GACb,EAAE,sBAAsB,2CAoDxB"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { PaymentStep, } from "@voyantjs/checkout-ui";
|
|
4
|
+
import { Landmark } from "lucide-react";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
/**
|
|
7
|
+
* Flight-vertical wrapper around `<PaymentStep>` from `@voyantjs/checkout-ui`.
|
|
8
|
+
* Maps the universal `PaymentChoice` event into the flight contract's
|
|
9
|
+
* `PaymentIntent` shape, and contributes the "Issue ticket on agency
|
|
10
|
+
* credit" extra option (flight-specific).
|
|
11
|
+
*/
|
|
12
|
+
export function FlightPaymentStep({ value, onChange, savedMethods, loadingSavedMethods, selectedSavedId, onSelectSaved, capabilities, }) {
|
|
13
|
+
const choice = useMemo(() => intentToChoice(value, savedMethods, selectedSavedId), [value, savedMethods, selectedSavedId]);
|
|
14
|
+
return (_jsx(PaymentStep, { value: choice, onChange: (next) => {
|
|
15
|
+
if (!next) {
|
|
16
|
+
onChange({ type: "hold" });
|
|
17
|
+
onSelectSaved(null);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (next.type === "saved_method") {
|
|
21
|
+
onSelectSaved(next.method.id);
|
|
22
|
+
// The CRM `processor_token` for the method isn't on the
|
|
23
|
+
// PublicPaymentAccount projection. The vertical wrapper here
|
|
24
|
+
// emits a placeholder token derived from the account id; the
|
|
25
|
+
// parent is expected to call `useInitiateCheckoutCollection`
|
|
26
|
+
// with `paymentInstrumentId: next.method.id` rather than
|
|
27
|
+
// relying on the flight `PaymentIntent.token` field.
|
|
28
|
+
onChange({
|
|
29
|
+
type: "card",
|
|
30
|
+
token: `acct:${next.method.id}`,
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
onSelectSaved(null);
|
|
35
|
+
if (next.type === "new_card") {
|
|
36
|
+
onChange({
|
|
37
|
+
type: "card",
|
|
38
|
+
token: next.cardToken,
|
|
39
|
+
...(next.cardholderName ? { cardholderName: next.cardholderName } : {}),
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (next.type === "extra" && next.optionId === EXTRA_AGENCY_CREDIT.id) {
|
|
44
|
+
onChange({ type: "ticket_on_credit" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// `hold` at the contract level — the parent's order-creation flow
|
|
48
|
+
// produces a payment session + landing URL the operator shares.
|
|
49
|
+
onChange({ type: "hold" });
|
|
50
|
+
}, capabilities: capabilities ?? {}, savedMethods: savedMethods, loadingSavedMethods: loadingSavedMethods, extraOptions: FLIGHT_EXTRA_OPTIONS }));
|
|
51
|
+
}
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// Flight-specific extras
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
const EXTRA_AGENCY_CREDIT = {
|
|
56
|
+
id: "ticket_on_credit",
|
|
57
|
+
label: "Issue ticket on agency credit",
|
|
58
|
+
description: "Bill against the agency's IATA / consolidator credit line.",
|
|
59
|
+
icon: _jsx(Landmark, { className: "h-4 w-4 text-muted-foreground" }),
|
|
60
|
+
};
|
|
61
|
+
const FLIGHT_EXTRA_OPTIONS = [EXTRA_AGENCY_CREDIT];
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// PaymentIntent ⇄ PaymentChoice translation
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
function intentToChoice(intent, savedMethods, selectedSavedId) {
|
|
66
|
+
if (intent.type === "ticket_on_credit") {
|
|
67
|
+
return { type: "extra", optionId: EXTRA_AGENCY_CREDIT.id };
|
|
68
|
+
}
|
|
69
|
+
if (intent.type === "card") {
|
|
70
|
+
if (selectedSavedId) {
|
|
71
|
+
const method = savedMethods.find((m) => m.id === selectedSavedId);
|
|
72
|
+
if (method)
|
|
73
|
+
return { type: "saved_method", method };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
type: "new_card",
|
|
77
|
+
cardToken: intent.token,
|
|
78
|
+
...(intent.cardholderName ? { cardholderName: intent.cardholderName } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { type: "hold" };
|
|
82
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FlightSearchRequest } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export type TripType = "one_way" | "round_trip";
|
|
3
|
+
export interface FlightSearchFormProps {
|
|
4
|
+
/** Called when the user submits a complete search. */
|
|
5
|
+
onSearch: (request: FlightSearchRequest) => void;
|
|
6
|
+
/** Disable the submit button (e.g. while a search is in flight). */
|
|
7
|
+
loading?: boolean;
|
|
8
|
+
/** Optional initial values. */
|
|
9
|
+
initial?: Partial<FlightSearchRequest> & {
|
|
10
|
+
tripType?: TripType;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export declare function FlightSearchForm({ onSearch, loading, initial }: FlightSearchFormProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
//# sourceMappingURL=flight-search-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-search-form.d.ts","sourceRoot":"","sources":["../../src/components/flight-search-form.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,mBAAmB,EAGpB,MAAM,kCAAkC,CAAA;AAUzC,MAAM,MAAM,QAAQ,GAAG,SAAS,GAAG,YAAY,CAAA;AAE/C,MAAM,WAAW,qBAAqB;IACpC,sDAAsD;IACtD,QAAQ,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAA;IAChD,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC,mBAAmB,CAAC,GAAG;QAAE,QAAQ,CAAC,EAAE,QAAQ,CAAA;KAAE,CAAA;CACjE;AAED,wBAAgB,gBAAgB,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE,qBAAqB,2CAmIrF"}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { DatePicker } from "@voyantjs/ui/components/date-picker";
|
|
5
|
+
import { ToggleGroup, ToggleGroupItem } from "@voyantjs/ui/components/toggle-group";
|
|
6
|
+
import { ArrowLeftRight, Search } from "lucide-react";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { AirportCombobox } from "./airport-combobox";
|
|
9
|
+
import { PaxCabinPopover } from "./pax-cabin-popover";
|
|
10
|
+
export function FlightSearchForm({ onSearch, loading, initial }) {
|
|
11
|
+
const initialSlices = initial?.slices ?? [];
|
|
12
|
+
const [tripType, setTripType] = useState(initial?.tripType ?? (initialSlices.length === 2 ? "round_trip" : "one_way"));
|
|
13
|
+
const [origin, setOrigin] = useState(initialSlices[0]?.origin ?? null);
|
|
14
|
+
const [destination, setDestination] = useState(initialSlices[0]?.destination ?? null);
|
|
15
|
+
const [departureDate, setDepartureDate] = useState(initialSlices[0]?.departureDate ?? null);
|
|
16
|
+
const [returnDate, setReturnDate] = useState(initialSlices[1]?.departureDate ?? null);
|
|
17
|
+
const initialPax = initial?.passengers ?? {
|
|
18
|
+
adults: 1,
|
|
19
|
+
children: 0,
|
|
20
|
+
infants: 0,
|
|
21
|
+
};
|
|
22
|
+
const [passengers, setPassengers] = useState(initialPax);
|
|
23
|
+
const [cabin, setCabin] = useState(initial?.cabin ?? "economy");
|
|
24
|
+
const swap = () => {
|
|
25
|
+
const o = origin;
|
|
26
|
+
setOrigin(destination);
|
|
27
|
+
setDestination(o);
|
|
28
|
+
};
|
|
29
|
+
const ready = origin != null &&
|
|
30
|
+
destination != null &&
|
|
31
|
+
departureDate != null &&
|
|
32
|
+
(tripType === "one_way" || returnDate != null) &&
|
|
33
|
+
passengers.adults > 0;
|
|
34
|
+
const submit = (e) => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
if (!ready || origin == null || destination == null || departureDate == null)
|
|
37
|
+
return;
|
|
38
|
+
const slices = [{ origin, destination, departureDate }];
|
|
39
|
+
if (tripType === "round_trip" && returnDate) {
|
|
40
|
+
slices.push({
|
|
41
|
+
origin: destination,
|
|
42
|
+
destination: origin,
|
|
43
|
+
departureDate: returnDate,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
onSearch({ slices, passengers, cabin });
|
|
47
|
+
};
|
|
48
|
+
return (_jsx("form", { onSubmit: submit, className: "rounded-xl border bg-card px-5 py-5 shadow-sm", children: _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsxs(ToggleGroup, { size: "lg", value: [tripType], onValueChange: (v) => {
|
|
49
|
+
const next = v[0];
|
|
50
|
+
if (next)
|
|
51
|
+
setTripType(next);
|
|
52
|
+
}, children: [_jsx(ToggleGroupItem, { size: "lg", value: "round_trip", children: "Round-trip" }), _jsx(ToggleGroupItem, { size: "lg", value: "one_way", children: "One-way" })] }), _jsxs("div", { className: "flex flex-1 items-center gap-1", children: [_jsx(AirportCombobox, { value: origin, onChange: setOrigin, placeholder: "From", className: "flex-1" }), _jsx(Button, { type: "button", variant: "outline", size: "icon", onClick: swap, "aria-label": "Swap origin and destination", className: "size-10 shrink-0", children: _jsx(ArrowLeftRight, { className: "h-4 w-4" }) }), _jsx(AirportCombobox, { value: destination, onChange: setDestination, placeholder: "To", className: "flex-1" })] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(DatePicker, { value: departureDate, onChange: setDepartureDate, placeholder: "Depart", className: "h-10 flex-1 min-w-32" }), _jsx(DatePicker, { value: returnDate, onChange: setReturnDate, placeholder: "Return", disabled: tripType === "one_way", className: "h-10 flex-1 min-w-32" })] }), _jsx(PaxCabinPopover, { passengers: passengers, cabin: cabin, onChange: (next) => {
|
|
53
|
+
setPassengers(next.passengers);
|
|
54
|
+
setCabin(next.cabin);
|
|
55
|
+
} }), _jsxs(Button, { type: "submit", size: "lg", disabled: !ready || loading, className: "shrink-0 px-6", children: [_jsx(Search, { className: "mr-2 h-4 w-4" }), loading ? "Searching…" : "Search"] })] }) }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Seat, SeatMap } from "@voyantjs/flights/contract/types";
|
|
2
|
+
/**
|
|
3
|
+
* Marker rendered on top of a seat to indicate which passenger picked it.
|
|
4
|
+
* Letter is typically the pax index (1, 2, 3) or initial.
|
|
5
|
+
*/
|
|
6
|
+
export interface SeatPickMarker {
|
|
7
|
+
seatNumber: string;
|
|
8
|
+
/** Single character / short label shown on the seat tile. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Tailwind colour utility group, e.g. "bg-primary text-primary-foreground". */
|
|
11
|
+
swatch?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface FlightSeatMapProps {
|
|
14
|
+
seatMap: SeatMap;
|
|
15
|
+
/** Active pick markers — typically one per passenger that picked a seat. */
|
|
16
|
+
picks: SeatPickMarker[];
|
|
17
|
+
/** Click handler — invoked with the seat that was clicked (or null on blocked). */
|
|
18
|
+
onSeatClick?: (seat: Seat) => void;
|
|
19
|
+
/** Marker shown on the row currently being assigned (e.g. "Adult 1"). */
|
|
20
|
+
highlightedPaxLabel?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Visual seat map. Renders each row of the aircraft using the supplied
|
|
25
|
+
* `columnLayout` (with `null` slots becoming aisles), each seat as a
|
|
26
|
+
* clickable tile colour-coded by status + category. Pick markers overlay
|
|
27
|
+
* the seat to indicate which passenger has that seat.
|
|
28
|
+
*
|
|
29
|
+
* Pure presentational — state lives in the parent step.
|
|
30
|
+
*/
|
|
31
|
+
export declare function FlightSeatMap({ seatMap, picks, onSeatClick, highlightedPaxLabel, className, }: FlightSeatMapProps): import("react/jsx-runtime").JSX.Element;
|
|
32
|
+
//# sourceMappingURL=flight-seat-map.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-seat-map.d.ts","sourceRoot":"","sources":["../../src/components/flight-seat-map.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAA;AAUrE;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,4EAA4E;IAC5E,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,mFAAmF;IACnF,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAA;IAClC,yEAAyE;IACzE,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,EAC5B,OAAO,EACP,KAAK,EACL,WAAW,EACX,mBAAmB,EACnB,SAAS,GACV,EAAE,kBAAkB,2CA0BpB"}
|