@voyantjs/flights-ui 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/components/airline-logo.d.ts +19 -0
  2. package/dist/components/airline-logo.d.ts.map +1 -0
  3. package/dist/components/airline-logo.js +18 -0
  4. package/dist/components/airport-combobox.d.ts +20 -0
  5. package/dist/components/airport-combobox.d.ts.map +1 -0
  6. package/dist/components/airport-combobox.js +29 -0
  7. package/dist/components/flight-baggage-step.d.ts +32 -0
  8. package/dist/components/flight-baggage-step.d.ts.map +1 -0
  9. package/dist/components/flight-baggage-step.js +106 -0
  10. package/dist/components/flight-billing-step.d.ts +69 -0
  11. package/dist/components/flight-billing-step.d.ts.map +1 -0
  12. package/dist/components/flight-billing-step.js +111 -0
  13. package/dist/components/flight-booking-journey.d.ts +31 -0
  14. package/dist/components/flight-booking-journey.d.ts.map +1 -0
  15. package/dist/components/flight-booking-journey.js +114 -0
  16. package/dist/components/flight-booking-ledger.d.ts +53 -0
  17. package/dist/components/flight-booking-ledger.d.ts.map +1 -0
  18. package/dist/components/flight-booking-ledger.js +94 -0
  19. package/dist/components/flight-booking-shell.d.ts +92 -0
  20. package/dist/components/flight-booking-shell.d.ts.map +1 -0
  21. package/dist/components/flight-booking-shell.js +486 -0
  22. package/dist/components/flight-contact-form.d.ts +16 -0
  23. package/dist/components/flight-contact-form.d.ts.map +1 -0
  24. package/dist/components/flight-contact-form.js +21 -0
  25. package/dist/components/flight-fare-upsell-step.d.ts +26 -0
  26. package/dist/components/flight-fare-upsell-step.d.ts.map +1 -0
  27. package/dist/components/flight-fare-upsell-step.js +141 -0
  28. package/dist/components/flight-filters-bar.d.ts +19 -0
  29. package/dist/components/flight-filters-bar.d.ts.map +1 -0
  30. package/dist/components/flight-filters-bar.js +90 -0
  31. package/dist/components/flight-itinerary.d.ts +28 -0
  32. package/dist/components/flight-itinerary.d.ts.map +1 -0
  33. package/dist/components/flight-itinerary.js +90 -0
  34. package/dist/components/flight-offer-detail.d.ts +21 -0
  35. package/dist/components/flight-offer-detail.d.ts.map +1 -0
  36. package/dist/components/flight-offer-detail.js +61 -0
  37. package/dist/components/flight-offer-row.d.ts +25 -0
  38. package/dist/components/flight-offer-row.d.ts.map +1 -0
  39. package/dist/components/flight-offer-row.js +74 -0
  40. package/dist/components/flight-order-confirmation.d.ts +13 -0
  41. package/dist/components/flight-order-confirmation.d.ts.map +1 -0
  42. package/dist/components/flight-order-confirmation.js +50 -0
  43. package/dist/components/flight-passenger-form.d.ts +49 -0
  44. package/dist/components/flight-passenger-form.d.ts.map +1 -0
  45. package/dist/components/flight-passenger-form.js +155 -0
  46. package/dist/components/flight-payment-selector.d.ts +13 -0
  47. package/dist/components/flight-payment-selector.d.ts.map +1 -0
  48. package/dist/components/flight-payment-selector.js +36 -0
  49. package/dist/components/flight-payment-step.d.ts +32 -0
  50. package/dist/components/flight-payment-step.d.ts.map +1 -0
  51. package/dist/components/flight-payment-step.js +82 -0
  52. package/dist/components/flight-search-form.d.ts +14 -0
  53. package/dist/components/flight-search-form.d.ts.map +1 -0
  54. package/dist/components/flight-search-form.js +56 -0
  55. package/dist/components/flight-seat-map.d.ts +32 -0
  56. package/dist/components/flight-seat-map.d.ts.map +1 -0
  57. package/dist/components/flight-seat-map.js +96 -0
  58. package/dist/components/flight-seats-step.d.ts +40 -0
  59. package/dist/components/flight-seats-step.d.ts.map +1 -0
  60. package/dist/components/flight-seats-step.js +211 -0
  61. package/dist/components/flight-services-step.d.ts +27 -0
  62. package/dist/components/flight-services-step.d.ts.map +1 -0
  63. package/dist/components/flight-services-step.js +110 -0
  64. package/dist/components/pax-cabin-popover.d.ts +18 -0
  65. package/dist/components/pax-cabin-popover.d.ts.map +1 -0
  66. package/dist/components/pax-cabin-popover.js +38 -0
  67. package/dist/components/popular-routes.d.ts +47 -0
  68. package/dist/components/popular-routes.d.ts.map +1 -0
  69. package/dist/components/popular-routes.js +126 -0
  70. package/dist/index.d.ts +26 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +23 -0
  73. package/package.json +77 -0
  74. package/src/styles.css +1 -0
@@ -0,0 +1,19 @@
1
+ export interface AirlineLogoProps {
2
+ /** 2- or 3-char IATA carrier code. */
3
+ iataCode: string;
4
+ /** Optional override; defaults to Kayak's public logo CDN. */
5
+ logoUrl?: string | null;
6
+ /** Display name for `alt` text + initials fallback. */
7
+ name?: string;
8
+ /** Pixel size of the logo box. Default 28. */
9
+ size?: number;
10
+ className?: string;
11
+ }
12
+ /**
13
+ * Inline carrier logo. Falls back to a colored initials chip when the
14
+ * image fails to load (e.g. unknown carrier, blocked CDN). Initials are
15
+ * the IATA code so they're never wrong even when the airline name isn't
16
+ * known to the operator's reference data.
17
+ */
18
+ export declare function AirlineLogo({ iataCode, logoUrl, name, size, className }: AirlineLogoProps): import("react/jsx-runtime").JSX.Element;
19
+ //# sourceMappingURL=airline-logo.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"airline-logo.d.ts","sourceRoot":"","sources":["../../src/components/airline-logo.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,gBAAgB;IAC/B,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAA;IAChB,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8CAA8C;IAC9C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,IAAS,EAAE,SAAS,EAAE,EAAE,gBAAgB,2CAgC9F"}
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { cn } from "@voyantjs/ui/lib/utils";
4
+ import { useState } from "react";
5
+ /**
6
+ * Inline carrier logo. Falls back to a colored initials chip when the
7
+ * image fails to load (e.g. unknown carrier, blocked CDN). Initials are
8
+ * the IATA code so they're never wrong even when the airline name isn't
9
+ * known to the operator's reference data.
10
+ */
11
+ export function AirlineLogo({ iataCode, logoUrl, name, size = 28, className }) {
12
+ const [errored, setErrored] = useState(false);
13
+ const url = logoUrl ?? `https://www.kayak.com/h/run/airline-logos/${iataCode}.png`;
14
+ if (errored || !iataCode) {
15
+ return (_jsx("div", { className: cn("flex shrink-0 items-center justify-center rounded bg-muted font-mono text-[10px] font-medium text-muted-foreground", className), style: { width: size, height: size }, role: "img", "aria-label": name ?? iataCode, children: iataCode }));
16
+ }
17
+ return (_jsx("img", { src: url, alt: name ?? iataCode, width: size, height: size, loading: "lazy", onError: () => setErrored(true), className: cn("shrink-0 rounded object-contain", className), style: { width: size, height: size } }));
18
+ }
@@ -0,0 +1,20 @@
1
+ import { type AirportDto } from "@voyantjs/flights-react";
2
+ export interface AirportComboboxProps {
3
+ /** Selected IATA code, or null when nothing is selected. */
4
+ value: string | null;
5
+ onChange: (next: string | null, airport: AirportDto | null) => void;
6
+ /** Trigger placeholder when nothing is selected (e.g. "From", "To"). */
7
+ placeholder?: string;
8
+ className?: string;
9
+ disabled?: boolean;
10
+ }
11
+ /**
12
+ * Single-line typeahead airport picker. Trigger reads as one of:
13
+ * - placeholder (no selection)
14
+ * - "LHR · London" (selection in current result set)
15
+ * - "LHR" (selection but airport not in current result page)
16
+ *
17
+ * Backed by `useAirportSearch` (debounced server query).
18
+ */
19
+ export declare function AirportCombobox({ value, onChange, placeholder, className, disabled, }: AirportComboboxProps): import("react/jsx-runtime").JSX.Element;
20
+ //# sourceMappingURL=airport-combobox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"airport-combobox.d.ts","sourceRoot":"","sources":["../../src/components/airport-combobox.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,yBAAyB,CAAA;AAe3E,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,UAAU,GAAG,IAAI,KAAK,IAAI,CAAA;IACnE,wEAAwE;IACxE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,QAAQ,EACR,WAAuB,EACvB,SAAS,EACT,QAAQ,GACT,EAAE,oBAAoB,2CAwEtB"}
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useAirportSearch } from "@voyantjs/flights-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 { cn } from "@voyantjs/ui/lib/utils";
8
+ import { ChevronDown, MapPin } from "lucide-react";
9
+ import { useState } from "react";
10
+ /**
11
+ * Single-line typeahead airport picker. Trigger reads as one of:
12
+ * - placeholder (no selection)
13
+ * - "LHR · London" (selection in current result set)
14
+ * - "LHR" (selection but airport not in current result page)
15
+ *
16
+ * Backed by `useAirportSearch` (debounced server query).
17
+ */
18
+ export function AirportCombobox({ value, onChange, placeholder = "Airport", className, disabled, }) {
19
+ const [open, setOpen] = useState(false);
20
+ const [input, setInput] = useState("");
21
+ const search = useAirportSearch(input, { enabled: open, limit: 30 });
22
+ const airports = search.data?.data ?? [];
23
+ const selected = value ? airports.find((a) => a.iataCode === value) : null;
24
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsxs(PopoverTrigger, { render: _jsx(Button, { type: "button", variant: "outline", disabled: disabled, className: cn("h-10 justify-between gap-2 px-3", className) }), children: [_jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [_jsx(MapPin, { className: "h-4 w-4 shrink-0 text-muted-foreground" }), value ? (_jsxs("span", { className: "truncate text-sm", children: [_jsx("span", { className: "font-mono font-medium", children: value }), selected && (_jsx("span", { className: "ml-1.5 font-normal text-muted-foreground", children: selected.city }))] })) : (_jsx("span", { className: "truncate text-sm text-muted-foreground", children: placeholder }))] }), _jsx(ChevronDown, { className: "h-4 w-4 shrink-0 text-muted-foreground" })] }), _jsx(PopoverContent, { className: "w-[320px] p-0", align: "start", children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { value: input, onValueChange: setInput, placeholder: "Type city or IATA code\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: search.isLoading ? "Searching…" : "No airports." }), _jsx(CommandGroup, { children: airports.map((a) => (_jsxs(CommandItem, { value: `${a.iataCode} ${a.city} ${a.name}`, onSelect: () => {
25
+ onChange(a.iataCode, a);
26
+ setOpen(false);
27
+ setInput("");
28
+ }, children: [_jsx("span", { className: "mr-2 inline-flex w-10 justify-center rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium", children: a.iataCode }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate text-sm", children: a.city }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: a.name })] }), _jsx("span", { className: "ml-2 text-[10px] uppercase text-muted-foreground", children: a.country })] }, a.iataCode))) })] })] }) })] }));
29
+ }
@@ -0,0 +1,32 @@
1
+ import type { AncillaryCatalog, AncillarySelection, FlightOffer, FlightPassenger, PassengerCounts } from "@voyantjs/flights/contract/types";
2
+ type BaggagePicks = NonNullable<AncillarySelection["baggage"]>;
3
+ export interface FlightBaggageStepProps {
4
+ /** Per-leg catalogs. Outbound is required; return only when round-trip. */
5
+ outboundCatalog: AncillaryCatalog | null;
6
+ returnCatalog?: AncillaryCatalog | null;
7
+ /** Carrier-friendly leg labels for cards. */
8
+ outboundOffer: FlightOffer;
9
+ returnOffer?: FlightOffer;
10
+ /**
11
+ * Passengers, in slot order. Pulled from the passengers step for labels;
12
+ * if some are still blank-named, fall back to "Adult 1", "Child 1", etc.
13
+ */
14
+ passengers: FlightPassenger[];
15
+ /** Fallback when passengers haven't been entered yet. */
16
+ passengerCounts: PassengerCounts;
17
+ value: BaggagePicks;
18
+ onChange: (next: BaggagePicks) => void;
19
+ /** Mirror outbound picks to return — UI toggle, defaults true on round-trip. */
20
+ sameForBothDirections: boolean;
21
+ onSameForBothDirectionsChange: (next: boolean) => void;
22
+ loading?: boolean;
23
+ }
24
+ /**
25
+ * Wizz-style baggage step. Tiered grid (10/20/26/32 kg with "Recommended"
26
+ * highlight) per passenger per leg, plus a "skip checked bag" path. The
27
+ * "same for both directions" toggle mirrors outbound picks to the return
28
+ * leg — kept on by default per LCC convention.
29
+ */
30
+ export declare function FlightBaggageStep({ outboundCatalog, returnCatalog, outboundOffer, returnOffer, passengers, passengerCounts, value, onChange, sameForBothDirections, onSameForBothDirectionsChange, loading, }: FlightBaggageStepProps): import("react/jsx-runtime").JSX.Element;
31
+ export {};
32
+ //# sourceMappingURL=flight-baggage-step.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flight-baggage-step.d.ts","sourceRoot":"","sources":["../../src/components/flight-baggage-step.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,gBAAgB,EAChB,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,eAAe,EAChB,MAAM,kCAAkC,CAAA;AAOzC,KAAK,YAAY,GAAG,WAAW,CAAC,kBAAkB,CAAC,SAAS,CAAC,CAAC,CAAA;AAG9D,MAAM,WAAW,sBAAsB;IACrC,2EAA2E;IAC3E,eAAe,EAAE,gBAAgB,GAAG,IAAI,CAAA;IACxC,aAAa,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAA;IACvC,6CAA6C;IAC7C,aAAa,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB;;;OAGG;IACH,UAAU,EAAE,eAAe,EAAE,CAAA;IAC7B,yDAAyD;IACzD,eAAe,EAAE,eAAe,CAAA;IAChC,KAAK,EAAE,YAAY,CAAA;IACnB,QAAQ,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAA;IACtC,gFAAgF;IAChF,qBAAqB,EAAE,OAAO,CAAA;IAC9B,6BAA6B,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACtD,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,eAAe,EACf,aAAa,EACb,aAAa,EACb,WAAW,EACX,UAAU,EACV,eAAe,EACf,KAAK,EACL,QAAQ,EACR,qBAAqB,EACrB,6BAA6B,EAC7B,OAAO,GACR,EAAE,sBAAsB,2CA0FxB"}
@@ -0,0 +1,106 @@
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 { Label } from "@voyantjs/ui/components/label";
5
+ import { cn } from "@voyantjs/ui/lib/utils";
6
+ import { Briefcase, CheckCircle2, Luggage } from "lucide-react";
7
+ import { useMemo } from "react";
8
+ /**
9
+ * Wizz-style baggage step. Tiered grid (10/20/26/32 kg with "Recommended"
10
+ * highlight) per passenger per leg, plus a "skip checked bag" path. The
11
+ * "same for both directions" toggle mirrors outbound picks to the return
12
+ * leg — kept on by default per LCC convention.
13
+ */
14
+ export function FlightBaggageStep({ outboundCatalog, returnCatalog, outboundOffer, returnOffer, passengers, passengerCounts, value, onChange, sameForBothDirections, onSameForBothDirectionsChange, loading, }) {
15
+ const isRoundTrip = !!returnOffer;
16
+ const paxRows = useMemo(() => buildPassengerRows(passengers, passengerCounts), [passengers, passengerCounts]);
17
+ if (loading) {
18
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("div", { className: "h-8 w-64 animate-pulse rounded bg-muted/40" }), _jsx("div", { className: "h-40 animate-pulse rounded-xl bg-muted/40" }), _jsx("div", { className: "h-40 animate-pulse rounded-xl bg-muted/40" })] }));
19
+ }
20
+ if (!outboundCatalog) {
21
+ return (_jsx("div", { className: "rounded-xl border border-dashed p-6 text-center text-muted-foreground text-sm", children: "Bags couldn't be loaded for this offer." }));
22
+ }
23
+ const setPick = (next, removeMatch) => {
24
+ const filtered = value.filter((p) => !(p.passengerId === removeMatch.passengerId && p.sliceIndex === removeMatch.sliceIndex));
25
+ const updated = next ? [...filtered, next] : filtered;
26
+ if (sameForBothDirections && isRoundTrip && removeMatch.sliceIndex === 0) {
27
+ // Mirror to return leg (slice 1) — strip then re-add the same option.
28
+ const noReturn = updated.filter((p) => !(p.passengerId === removeMatch.passengerId && p.sliceIndex === 1));
29
+ const mirrored = next ? [...noReturn, { ...next, sliceIndex: 1 }] : noReturn;
30
+ onChange(mirrored);
31
+ return;
32
+ }
33
+ onChange(updated);
34
+ };
35
+ return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h2", { className: "font-semibold text-base", children: "Add checked baggage" }), _jsx("p", { className: "text-muted-foreground text-sm", children: "Carry-on under the seat is included. Pick a checked bag tier per passenger or skip this step." })] }), isRoundTrip && (_jsxs("div", { className: "flex shrink-0 items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "baggage-same-for-both", checked: sameForBothDirections, onCheckedChange: (v) => onSameForBothDirectionsChange(!!v) }), _jsx("label", { htmlFor: "baggage-same-for-both", className: "cursor-pointer", children: "Same for both directions" })] }))] }), _jsx(BaggageLegSection, { legLabel: "Outbound", catalog: outboundCatalog, offer: outboundOffer, passengers: paxRows, sliceIndex: 0, value: value, onPick: setPick }), isRoundTrip && returnCatalog && !sameForBothDirections && (_jsx(BaggageLegSection, { legLabel: "Return", catalog: returnCatalog, offer: returnOffer ?? outboundOffer, passengers: paxRows, sliceIndex: 1, value: value, onPick: setPick }))] }));
36
+ }
37
+ function BaggageLegSection({ legLabel, catalog, offer, passengers, sliceIndex, value, onPick, }) {
38
+ const itin = offer.itineraries[0];
39
+ const first = itin?.segments[0];
40
+ const last = itin?.segments[itin.segments.length - 1];
41
+ return (_jsxs("section", { className: "rounded-xl border bg-card p-5 shadow-sm", children: [_jsxs("header", { className: "mb-4 flex items-baseline justify-between gap-2", children: [_jsxs("h3", { className: "font-medium text-sm", children: [_jsx(Luggage, { className: "mr-1.5 inline h-3.5 w-3.5 -translate-y-px text-muted-foreground" }), legLabel, " bags"] }), first && last && (_jsxs("span", { className: "text-muted-foreground text-xs", children: [first.departure.iataCode, " \u2192 ", last.arrival.iataCode, " \u00B7 ", formatDate(first.departure.at)] }))] }), _jsx("div", { className: "flex flex-col gap-5", children: passengers.map((pax) => {
42
+ const pick = value.find((p) => p.passengerId === pax.passengerId && p.sliceIndex === sliceIndex);
43
+ return (_jsx(PaxBaggageRow, { pax: pax, options: catalog.baggage, selectedOptionId: pick?.optionId ?? null, onSelect: (optionId) => onPick(optionId
44
+ ? { passengerId: pax.passengerId, sliceIndex, optionId, quantity: 1 }
45
+ : null, { passengerId: pax.passengerId, sliceIndex, optionId: "" }) }, pax.passengerId));
46
+ }) })] }));
47
+ }
48
+ function PaxBaggageRow({ pax, options, selectedOptionId, onSelect, }) {
49
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(Label, { className: "font-medium text-sm", children: pax.label }), selectedOptionId == null && (_jsx("span", { className: "text-[11px] text-muted-foreground", children: "No checked bag" }))] }), _jsx("div", { className: "grid grid-cols-2 gap-2 md:grid-cols-4", children: options.map((opt) => {
50
+ const isSelected = selectedOptionId === opt.id;
51
+ return (_jsxs("button", { type: "button", onClick: () => onSelect(isSelected ? null : opt.id), className: cn("relative flex flex-col items-center justify-center gap-1.5 rounded-lg border bg-card p-3 text-center transition-colors", isSelected
52
+ ? "border-primary ring-2 ring-primary/20"
53
+ : "hover:border-primary/40 hover:bg-accent/30", opt.recommended && !isSelected && "border-primary/40"), children: [opt.recommended && (_jsx("span", { className: "-translate-y-1/2 absolute top-0 left-1/2 -translate-x-1/2 rounded-full bg-primary px-2 py-0.5 font-medium text-[9px] text-primary-foreground uppercase tracking-wider", children: "Recommended" })), isSelected && (_jsx(CheckCircle2, { className: "absolute top-2 right-2 h-3.5 w-3.5 text-primary" })), _jsx(Briefcase, { className: "h-7 w-7 text-muted-foreground" }), _jsx("span", { className: "font-semibold text-base", children: opt.weightKg ? `${opt.weightKg} kg` : opt.label }), _jsx("span", { className: "font-mono text-[11px] text-muted-foreground", children: opt.price.amount === "0.00"
54
+ ? "Included"
55
+ : `+${formatMoney(opt.price.amount, opt.price.currency)}` })] }, opt.id));
56
+ }) })] }));
57
+ }
58
+ // ── Helpers ──────────────────────────────────────────────────────────────────
59
+ function buildPassengerRows(passengers, counts) {
60
+ if (passengers.length > 0) {
61
+ return passengers.map((p) => ({
62
+ passengerId: p.passengerId,
63
+ label: nameOrFallback(p),
64
+ }));
65
+ }
66
+ // Synthesize from counts when passengers haven't been filled yet.
67
+ const out = [];
68
+ for (let i = 1; i <= counts.adults; i++) {
69
+ out.push({ passengerId: `pax_adult_${i}`, label: `Adult ${i}` });
70
+ }
71
+ for (let i = 1; i <= (counts.children ?? 0); i++) {
72
+ out.push({ passengerId: `pax_child_${i}`, label: `Child ${i}` });
73
+ }
74
+ for (let i = 1; i <= (counts.infants ?? 0); i++) {
75
+ out.push({ passengerId: `pax_infant_${i}`, label: `Infant ${i}` });
76
+ }
77
+ return out;
78
+ }
79
+ function nameOrFallback(p) {
80
+ const full = `${p.firstName} ${p.lastName}`.trim();
81
+ if (full)
82
+ return full;
83
+ const idx = p.passengerId.match(/_(\d+)$/)?.[1] ?? "1";
84
+ const cap = p.type[0]?.toUpperCase() + p.type.slice(1);
85
+ return `${cap} ${idx}`;
86
+ }
87
+ function formatMoney(amount, currency) {
88
+ const n = Number(amount);
89
+ if (!Number.isFinite(n))
90
+ return `${amount} ${currency}`;
91
+ return new Intl.NumberFormat(undefined, {
92
+ style: "currency",
93
+ currency,
94
+ maximumFractionDigits: 0,
95
+ }).format(n);
96
+ }
97
+ function formatDate(iso) {
98
+ const d = new Date(iso);
99
+ if (Number.isNaN(d.getTime()))
100
+ return iso;
101
+ return new Intl.DateTimeFormat(undefined, {
102
+ weekday: "short",
103
+ day: "numeric",
104
+ month: "short",
105
+ }).format(d);
106
+ }
@@ -0,0 +1,69 @@
1
+ import { type ReactNode } from "react";
2
+ /** Tab id — Privat (personal) vs Companie (business invoicing). */
3
+ export type BillingMode = "personal" | "company";
4
+ export interface BillingValue {
5
+ mode: BillingMode;
6
+ /** Personal: pax / billing-recipient first name. */
7
+ firstName: string;
8
+ /** Personal: pax / billing-recipient last name. */
9
+ lastName: string;
10
+ email: string;
11
+ phone?: string;
12
+ /** Postal address — same shape as `BillingAddress` from the contract. */
13
+ line1: string;
14
+ line2?: string;
15
+ city: string;
16
+ region?: string;
17
+ postalCode?: string;
18
+ /** ISO 3166-1 alpha-2. */
19
+ countryCode: string;
20
+ /** Company tab — required when mode === "company". */
21
+ companyName?: string;
22
+ vatNumber?: string;
23
+ /** "Save as default" toggle — parent decides what to do with it. */
24
+ saveAsDefault?: boolean;
25
+ }
26
+ /** A booking passenger eligible to be the billing recipient. */
27
+ export interface BillingEligiblePassenger {
28
+ id: string;
29
+ firstName: string;
30
+ middleName?: string;
31
+ lastName: string;
32
+ }
33
+ export interface FlightBillingStepProps {
34
+ value: BillingValue;
35
+ onChange: (next: BillingValue) => void;
36
+ /**
37
+ * Adult passengers from the booking who can stand in as the billing
38
+ * recipient. Children + infants are filtered out by the parent — a kid
39
+ * can never be the billing person. When non-empty, a "Pick from
40
+ * passengers" trigger appears alongside the contact picker.
41
+ */
42
+ eligiblePassengers?: BillingEligiblePassenger[];
43
+ /**
44
+ * Render slot for a person picker (e.g. CRM "Use details from contact").
45
+ * The parent supplies a CRM-aware picker that, on selection, calls
46
+ * `applyPrefill` with the relevant fields. Set null/undefined to omit.
47
+ */
48
+ renderPersonPicker?: (apply: (prefill: Partial<BillingValue>) => void) => ReactNode;
49
+ /**
50
+ * Render slot for an organization picker (Companie tab). On selection,
51
+ * `applyPrefill` is called with company name + VAT + address.
52
+ */
53
+ renderOrgPicker?: (apply: (prefill: Partial<BillingValue>) => void) => ReactNode;
54
+ }
55
+ /**
56
+ * Two-tab billing step with Privat (personal) + Companie (business / VAT)
57
+ * shapes. Address fields are structured (line1/city/postal/country) so the
58
+ * payload maps cleanly to `BillingAddress` on the payment intent. Pickers
59
+ * for prefill from CRM are supplied as render-prop slots so this component
60
+ * stays decoupled from the CRM data layer.
61
+ */
62
+ export declare function FlightBillingStep({ value, onChange, eligiblePassengers, renderPersonPicker, renderOrgPicker, }: FlightBillingStepProps): import("react/jsx-runtime").JSX.Element;
63
+ export declare function emptyBillingValue(): BillingValue;
64
+ /**
65
+ * Validate the billing value. Returns the first error message, or null
66
+ * when valid. Drives the journey's Continue gate.
67
+ */
68
+ export declare function validateBilling(v: BillingValue): string | null;
69
+ //# sourceMappingURL=flight-billing-step.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flight-billing-step.d.ts","sourceRoot":"","sources":["../../src/components/flight-billing-step.tsx"],"names":[],"mappings":"AAoBA,OAAO,EAAE,KAAK,SAAS,EAAY,MAAM,OAAO,CAAA;AAEhD,mEAAmE;AACnE,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,SAAS,CAAA;AAEhD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,WAAW,CAAA;IACjB,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAA;IACjB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAA;IACnB,sDAAsD;IACtD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,gEAAgE;AAChE,MAAM,WAAW,wBAAwB;IACvC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,YAAY,CAAA;IACnB,QAAQ,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAA;IACtC;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,wBAAwB,EAAE,CAAA;IAC/C;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,KAAK,SAAS,CAAA;IACnF;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,KAAK,SAAS,CAAA;CACjF;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,QAAQ,EACR,kBAAkB,EAClB,kBAAkB,EAClB,eAAe,GAChB,EAAE,sBAAsB,2CAiExB;AA8ND,wBAAgB,iBAAiB,IAAI,YAAY,CAUhD;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,GAAG,IAAI,CAc9D"}
@@ -0,0 +1,111 @@
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 { Checkbox } from "@voyantjs/ui/components/checkbox";
5
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@voyantjs/ui/components/command";
6
+ import { CountryCombobox } from "@voyantjs/ui/components/country-combobox";
7
+ import { Input } from "@voyantjs/ui/components/input";
8
+ import { Label } from "@voyantjs/ui/components/label";
9
+ import { PhoneInput } from "@voyantjs/ui/components/phone-input";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "@voyantjs/ui/components/popover";
11
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@voyantjs/ui/components/tabs";
12
+ import { cn } from "@voyantjs/ui/lib/utils";
13
+ import { Building2, ChevronDown, User, Users } from "lucide-react";
14
+ import { useState } from "react";
15
+ /**
16
+ * Two-tab billing step with Privat (personal) + Companie (business / VAT)
17
+ * shapes. Address fields are structured (line1/city/postal/country) so the
18
+ * payload maps cleanly to `BillingAddress` on the payment intent. Pickers
19
+ * for prefill from CRM are supplied as render-prop slots so this component
20
+ * stays decoupled from the CRM data layer.
21
+ */
22
+ export function FlightBillingStep({ value, onChange, eligiblePassengers, renderPersonPicker, renderOrgPicker, }) {
23
+ const apply = (prefill) => onChange({ ...value, ...prefill });
24
+ const set = (patch) => onChange({ ...value, ...patch });
25
+ const hasPassengerOptions = (eligiblePassengers?.length ?? 0) > 0;
26
+ return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { children: [_jsx("h2", { className: "font-semibold text-base", children: "Billing" }), _jsx("p", { className: "text-muted-foreground text-sm", children: "The receipt and tax documents will be issued in this name." })] }), _jsxs(Tabs, { value: value.mode, onValueChange: (v) => set({ mode: v ?? "personal" }), children: [_jsxs(TabsList, { children: [_jsxs(TabsTrigger, { value: "personal", children: [_jsx(User, { className: "mr-1.5 h-3.5 w-3.5" }), "Personal"] }), _jsxs(TabsTrigger, { value: "company", children: [_jsx(Building2, { className: "mr-1.5 h-3.5 w-3.5" }), "Company"] })] }), _jsxs(TabsContent, { value: "personal", className: "mt-5 flex flex-col gap-4", children: [(hasPassengerOptions || renderPersonPicker) && (_jsxs("div", { className: "flex flex-wrap justify-end gap-2", children: [hasPassengerOptions && (_jsx(PassengerPickerTrigger, { passengers: eligiblePassengers ?? [], onPick: (p) => apply({
27
+ mode: "personal",
28
+ firstName: p.firstName,
29
+ ...(p.middleName ? { middleName: p.middleName } : {}),
30
+ lastName: p.lastName,
31
+ }) })), renderPersonPicker?.(apply)] })), _jsx(NameRow, { value: value, onChange: set }), _jsx(ContactRow, { value: value, onChange: set }), _jsx(AddressBlock, { value: value, onChange: set }), _jsx(SaveDefaultRow, { value: value, onChange: set })] }), _jsxs(TabsContent, { value: "company", className: "mt-5 flex flex-col gap-4", children: [renderOrgPicker && _jsx("div", { className: "flex justify-end", children: renderOrgPicker(apply) }), _jsx(CompanyRow, { value: value, onChange: set }), _jsx(ContactRow, { value: value, onChange: set, workPhone: true }), _jsx(AddressBlock, { value: value, onChange: set }), _jsx(SaveDefaultRow, { value: value, onChange: set })] })] })] }));
32
+ }
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ function NameRow({ value, onChange, }) {
35
+ return (_jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [_jsx(Field, { label: "First name", required: true, children: _jsx(Input, { value: value.firstName, onChange: (e) => onChange({ firstName: e.target.value }) }) }), _jsx(Field, { label: "Last name", required: true, children: _jsx(Input, { value: value.lastName, onChange: (e) => onChange({ lastName: e.target.value }) }) })] }));
36
+ }
37
+ function CompanyRow({ value, onChange, }) {
38
+ return (_jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [_jsx(Field, { label: "Company name", required: true, children: _jsx(Input, { value: value.companyName ?? "", onChange: (e) => onChange({ companyName: e.target.value }) }) }), _jsx(Field, { label: "Tax id / VAT number", required: true, children: _jsx(Input, { value: value.vatNumber ?? "", onChange: (e) => onChange({ vatNumber: e.target.value }), placeholder: "e.g. RO43917962" }) })] }));
39
+ }
40
+ function ContactRow({ value, onChange, workPhone, }) {
41
+ return (_jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [_jsx(Field, { label: "Email", required: true, children: _jsx(Input, { type: "email", value: value.email, onChange: (e) => onChange({ email: e.target.value }) }) }), _jsx(Field, { label: workPhone ? "Work phone" : "Phone", children: _jsx(PhoneInput, { value: (value.phone ?? ""), onChange: (v) => onChange({ phone: v ? String(v) : undefined }), defaultCountry: "RO", international: true }) })] }));
42
+ }
43
+ function AddressBlock({ value, onChange, }) {
44
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx(Field, { label: "Street address", required: true, children: _jsx(Input, { value: value.line1, onChange: (e) => onChange({ line1: e.target.value }), placeholder: "Street + number" }) }), _jsx(Field, { label: "Address line 2", children: _jsx(Input, { value: value.line2 ?? "", onChange: (e) => onChange({ line2: e.target.value }), placeholder: "Apartment, suite, etc." }) }), _jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-3", children: [_jsx(Field, { label: "City", required: true, children: _jsx(Input, { value: value.city, onChange: (e) => onChange({ city: e.target.value }) }) }), _jsx(Field, { label: "Postal code", children: _jsx(Input, { value: value.postalCode ?? "", onChange: (e) => onChange({ postalCode: e.target.value }) }) }), _jsx(Field, { label: "Country", required: true, children: _jsx(CountryCombobox, { value: value.countryCode || null, onChange: (code) => onChange({ countryCode: code ?? "" }) }) })] })] }));
45
+ }
46
+ function SaveDefaultRow({ value, onChange, }) {
47
+ const id = "billing-save-default";
48
+ return (_jsxs("div", { className: "flex items-center gap-2 text-muted-foreground text-sm", children: [_jsx(Checkbox, { id: id, checked: !!value.saveAsDefault, onCheckedChange: (v) => onChange({ saveAsDefault: !!v }) }), _jsx("label", { htmlFor: id, className: "cursor-pointer", children: "Save these details as the default for this contact" })] }));
49
+ }
50
+ function Field({ label, required, children, }) {
51
+ return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: cn("text-[11px] uppercase tracking-wider text-muted-foreground"), children: [label, required && _jsx("span", { className: "ml-0.5 text-destructive", children: "*" })] }), children] }));
52
+ }
53
+ // ── Helpers ──────────────────────────────────────────────────────────────────
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ /**
56
+ * Compact "Pick from passengers" popover. Lists adult passengers entered
57
+ * upstream — the operator can click one to copy their first/middle/last name
58
+ * into the billing recipient. Self-contained: doesn't depend on CRM data.
59
+ */
60
+ function PassengerPickerTrigger({ passengers, onPick, }) {
61
+ const [open, setOpen] = useState(false);
62
+ 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" }), "Pick from passengers", _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsx(PopoverContent, { className: "w-[320px] p-0", align: "end", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Search passengers\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "No matching passengers." }), _jsx(CommandGroup, { children: passengers.map((p) => {
63
+ const fullName = [p.firstName, p.middleName, p.lastName]
64
+ .filter((s) => s?.trim())
65
+ .join(" ");
66
+ return (_jsx(CommandItem, { value: fullName, onSelect: () => {
67
+ onPick(p);
68
+ setOpen(false);
69
+ }, children: _jsx("span", { className: "truncate font-medium text-sm", children: fullName || "—" }) }, p.id));
70
+ }) })] })] }) })] }));
71
+ }
72
+ export function emptyBillingValue() {
73
+ return {
74
+ mode: "personal",
75
+ firstName: "",
76
+ lastName: "",
77
+ email: "",
78
+ line1: "",
79
+ city: "",
80
+ countryCode: "",
81
+ };
82
+ }
83
+ /**
84
+ * Validate the billing value. Returns the first error message, or null
85
+ * when valid. Drives the journey's Continue gate.
86
+ */
87
+ export function validateBilling(v) {
88
+ if (!v.email.trim())
89
+ return "Email is required";
90
+ if (!/^\S+@\S+\.\S+$/.test(v.email.trim()))
91
+ return "Email looks invalid";
92
+ if (!v.line1.trim())
93
+ return "Street address is required";
94
+ if (!v.city.trim())
95
+ return "City is required";
96
+ if (!v.countryCode.trim())
97
+ return "Country is required";
98
+ if (v.mode === "personal") {
99
+ if (!v.firstName.trim())
100
+ return "First name is required";
101
+ if (!v.lastName.trim())
102
+ return "Last name is required";
103
+ }
104
+ else {
105
+ if (!v.companyName?.trim())
106
+ return "Company name is required";
107
+ if (!v.vatNumber?.trim())
108
+ return "VAT / tax number is required";
109
+ }
110
+ return null;
111
+ }
@@ -0,0 +1,31 @@
1
+ import type { FlightBookRequest, FlightOffer, FlightOrder, PassengerCounts } from "@voyantjs/flights/contract/types";
2
+ import { type FlightPassengerFormProps } from "./flight-passenger-form";
3
+ export interface FlightBookingJourneyProps {
4
+ /** The offer to book — usually returned by the priceOffer call. */
5
+ offer: FlightOffer;
6
+ /** Passenger counts captured at search time. */
7
+ passengers: PassengerCounts;
8
+ /** Submit handler — wire to the useFlightBook mutation. */
9
+ onBook: (request: FlightBookRequest) => Promise<FlightOrder> | FlightOrder;
10
+ /** Surfaced via book result; parent typically navigates to /orders/:id. */
11
+ onBooked?: (order: FlightOrder) => void;
12
+ /** "Back to results" affordance. */
13
+ onCancel?: () => void;
14
+ /** Optional formatter so passenger card shows airline/airport names. */
15
+ carrierName?: (iataCode: string) => string | undefined;
16
+ airportName?: (iataCode: string) => string | undefined;
17
+ /**
18
+ * Optional render slot for a person picker on each passenger card.
19
+ * Forwarded to `FlightPassengerForm.renderPicker` — operators wire a
20
+ * CRM-aware picker here so users can pick existing contacts as travelers.
21
+ */
22
+ renderPassengerPicker?: FlightPassengerFormProps["renderPicker"];
23
+ }
24
+ /**
25
+ * Multi-step booking journey: Review → Passengers → Contact + Payment →
26
+ * Confirm. Owns the in-progress form state; submits via `onBook` on the
27
+ * final step. Each step gates Continue with its own validator so the user
28
+ * can't advance with incomplete data.
29
+ */
30
+ export declare function FlightBookingJourney({ offer, passengers, onBook, onBooked, onCancel, carrierName, airportName, renderPassengerPicker, }: FlightBookingJourneyProps): import("react/jsx-runtime").JSX.Element | null;
31
+ //# sourceMappingURL=flight-booking-journey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flight-booking-journey.d.ts","sourceRoot":"","sources":["../../src/components/flight-booking-journey.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,iBAAiB,EACjB,WAAW,EACX,WAAW,EAEX,eAAe,EAEhB,MAAM,kCAAkC,CAAA;AAQzC,OAAO,EAEL,KAAK,wBAAwB,EAE9B,MAAM,yBAAyB,CAAA;AAYhC,MAAM,WAAW,yBAAyB;IACxC,mEAAmE;IACnE,KAAK,EAAE,WAAW,CAAA;IAClB,gDAAgD;IAChD,UAAU,EAAE,eAAe,CAAA;IAC3B,2DAA2D;IAC3D,MAAM,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CAAA;IAC1E,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACvC,oCAAoC;IACpC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,wEAAwE;IACxE,WAAW,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;IACtD,WAAW,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;IACtD;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,wBAAwB,CAAC,cAAc,CAAC,CAAA;CACjE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,KAAK,EACL,UAAU,EACV,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,WAAW,EACX,qBAAqB,GACtB,EAAE,yBAAyB,kDAmH3B"}
@@ -0,0 +1,114 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button } from "@voyantjs/ui/components/button";
4
+ import { cn } from "@voyantjs/ui/lib/utils";
5
+ import { Check, ChevronLeft, ChevronRight } from "lucide-react";
6
+ import { useState } from "react";
7
+ import { FlightContactForm, validateContact } from "./flight-contact-form";
8
+ import { FlightOfferDetail } from "./flight-offer-detail";
9
+ import { FlightPassengerForm, validatePassengers, } from "./flight-passenger-form";
10
+ import { FlightPaymentSelector } from "./flight-payment-selector";
11
+ const STEPS = [
12
+ { id: "review", label: "Review offer" },
13
+ { id: "passengers", label: "Passengers" },
14
+ { id: "contact", label: "Contact & payment" },
15
+ { id: "confirm", label: "Confirm" },
16
+ ];
17
+ /**
18
+ * Multi-step booking journey: Review → Passengers → Contact + Payment →
19
+ * Confirm. Owns the in-progress form state; submits via `onBook` on the
20
+ * final step. Each step gates Continue with its own validator so the user
21
+ * can't advance with incomplete data.
22
+ */
23
+ export function FlightBookingJourney({ offer, passengers, onBook, onBooked, onCancel, carrierName, airportName, renderPassengerPicker, }) {
24
+ const [stepIdx, setStepIdx] = useState(0);
25
+ const [paxList, setPaxList] = useState([]);
26
+ const [contact, setContact] = useState({});
27
+ const [payment, setPayment] = useState({ type: "hold" });
28
+ const [submitting, setSubmitting] = useState(false);
29
+ const [error, setError] = useState(null);
30
+ const step = STEPS[stepIdx];
31
+ if (!step)
32
+ return null;
33
+ const paxErrors = validatePassengers(paxList);
34
+ const contactError = validateContact(contact);
35
+ const canContinue = (() => {
36
+ switch (step.id) {
37
+ case "review":
38
+ return true;
39
+ case "passengers":
40
+ return Object.keys(paxErrors).length === 0 && paxList.length > 0;
41
+ case "contact":
42
+ return contactError == null;
43
+ case "confirm":
44
+ return true;
45
+ }
46
+ })();
47
+ const goNext = () => {
48
+ setStepIdx((i) => Math.min(STEPS.length - 1, i + 1));
49
+ };
50
+ const goBack = () => {
51
+ setStepIdx((i) => Math.max(0, i - 1));
52
+ };
53
+ const submit = async () => {
54
+ setError(null);
55
+ setSubmitting(true);
56
+ try {
57
+ const order = await onBook({
58
+ offerId: offer.offerId,
59
+ offer,
60
+ passengers: paxList,
61
+ contact,
62
+ paymentIntent: payment,
63
+ });
64
+ onBooked?.(order);
65
+ }
66
+ catch (err) {
67
+ setError(err instanceof Error ? err.message : String(err));
68
+ }
69
+ finally {
70
+ setSubmitting(false);
71
+ }
72
+ };
73
+ return (_jsxs("div", { className: "flex flex-col gap-6", children: [_jsx(Stepper, { currentIdx: stepIdx }), error && (_jsx("div", { className: "rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive", children: error })), _jsxs("div", { className: "flex flex-col gap-4", children: [step.id === "review" && (_jsxs("div", { className: "rounded-xl border bg-card p-5 shadow-sm", children: [_jsx("h2", { className: "mb-4 text-base font-semibold", children: "Review your selected flight" }), _jsx(FlightOfferDetail, { offer: offer, carrierName: carrierName, airportName: airportName })] })), step.id === "passengers" && (_jsx(FlightPassengerForm, { counts: passengers, value: paxList, onChange: setPaxList, renderPicker: renderPassengerPicker })), step.id === "contact" && (_jsxs(_Fragment, { children: [_jsx(FlightContactForm, { value: contact, onChange: setContact }), _jsx(FlightPaymentSelector, { value: payment, onChange: setPayment })] })), step.id === "confirm" && (_jsx(ConfirmSummary, { offer: offer, passengers: paxList, contact: contact, payment: payment }))] }), _jsxs("div", { className: "flex items-center justify-between border-t pt-4", children: [_jsxs(Button, { type: "button", variant: "ghost", onClick: () => (stepIdx === 0 ? onCancel?.() : goBack()), disabled: submitting, children: [_jsx(ChevronLeft, { className: "mr-1 h-4 w-4" }), stepIdx === 0 ? "Back to results" : "Back"] }), step.id === "confirm" ? (_jsx(Button, { onClick: submit, disabled: submitting, children: submitting ? "Booking…" : "Confirm booking" })) : (_jsxs(Button, { onClick: goNext, disabled: !canContinue, children: ["Continue", _jsx(ChevronRight, { className: "ml-1 h-4 w-4" })] }))] })] }));
74
+ }
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+ // Stepper indicator
77
+ // ─────────────────────────────────────────────────────────────────────────────
78
+ function Stepper({ currentIdx }) {
79
+ return (_jsx("ol", { className: "flex items-center gap-2", children: STEPS.map((s, i) => {
80
+ const isActive = i === currentIdx;
81
+ const isComplete = i < currentIdx;
82
+ return (_jsxs("li", { className: "flex flex-1 items-center gap-2", children: [_jsx("div", { className: cn("flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium tabular-nums", isComplete && "border-primary bg-primary text-primary-foreground", isActive && !isComplete && "border-primary text-primary", !isActive && !isComplete && "border-border text-muted-foreground"), children: isComplete ? _jsx(Check, { className: "h-3.5 w-3.5" }) : i + 1 }), _jsx("span", { className: cn("truncate text-sm", isActive ? "font-medium text-foreground" : "text-muted-foreground"), children: s.label }), i < STEPS.length - 1 && _jsx("div", { className: "h-px flex-1 bg-border" })] }, s.id));
83
+ }) }));
84
+ }
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // Final confirmation summary
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+ function ConfirmSummary({ offer, passengers, contact, payment, }) {
89
+ return (_jsxs("div", { className: "rounded-xl border bg-card p-5 shadow-sm", children: [_jsx("h2", { className: "mb-4 text-base font-semibold", children: "Confirm booking" }), _jsx(Row, { label: "Total", children: _jsx("span", { className: "font-semibold tabular-nums", children: formatMoney(offer.totalPrice.amount, offer.totalPrice.currency) }) }), _jsx(Row, { label: "Passengers", children: passengers.length }), _jsx(Row, { label: "Contact", children: contact.email ?? "—" }), _jsx(Row, { label: "Payment", children: _jsx("span", { className: "capitalize", children: payment.type.replace("_", " ") }) }), offer.expiresAt && (_jsx(Row, { label: "Offer expires", children: _jsx("time", { dateTime: offer.expiresAt, children: formatDateTime(offer.expiresAt) }) })), _jsx("p", { className: "mt-4 text-xs text-muted-foreground", children: "Submitting will hold seats with the connector and (depending on the chosen payment intent) either issue tickets immediately or open a ticketing window. Once confirmed, the booking appears under the order id below." })] }));
90
+ }
91
+ function Row({ label, children }) {
92
+ return (_jsxs("div", { className: "flex items-baseline justify-between border-b py-2 text-sm last:border-b-0", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsx("span", { children: children })] }));
93
+ }
94
+ function formatMoney(amount, currency) {
95
+ const n = Number(amount);
96
+ if (!Number.isFinite(n))
97
+ return `${amount} ${currency}`;
98
+ return new Intl.NumberFormat(undefined, {
99
+ style: "currency",
100
+ currency,
101
+ maximumFractionDigits: 0,
102
+ }).format(n);
103
+ }
104
+ function formatDateTime(iso) {
105
+ const d = new Date(iso);
106
+ if (Number.isNaN(d.getTime()))
107
+ return iso;
108
+ return new Intl.DateTimeFormat(undefined, {
109
+ day: "numeric",
110
+ month: "short",
111
+ hour: "2-digit",
112
+ minute: "2-digit",
113
+ }).format(d);
114
+ }