@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,141 @@
|
|
|
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 { cn } from "@voyantjs/ui/lib/utils";
|
|
5
|
+
import { Briefcase, Check, Crown, Luggage, Sparkles, X } from "lucide-react";
|
|
6
|
+
import { useMemo } from "react";
|
|
7
|
+
/**
|
|
8
|
+
* Per-pax per-leg branded-fare upsell step. For multi-pax bookings the
|
|
9
|
+
* "Same fare for all passengers" toggle (default ON) collapses the picker
|
|
10
|
+
* back to one card grid per leg, keeping the common case ("everyone on
|
|
11
|
+
* Standard") to one click. Toggling off splits each leg into per-pax card
|
|
12
|
+
* grids — useful for the "Adult 1 Plus, Adult 2 Basic" case full-service
|
|
13
|
+
* carriers + B2B agency bookings actually exercise.
|
|
14
|
+
*/
|
|
15
|
+
export function FlightFareUpsellStep({ outboundOffer, returnOffer, passengers, passengerCounts, value, onChange, sameForAllPassengers, onSameForAllPassengersChange, }) {
|
|
16
|
+
const paxRows = useMemo(() => buildPassengerRows(passengers, passengerCounts), [passengers, passengerCounts]);
|
|
17
|
+
const isMultiPax = paxRows.length > 1;
|
|
18
|
+
const outboundBundles = outboundOffer.fareBundles ?? [];
|
|
19
|
+
const returnBundles = returnOffer?.fareBundles ?? [];
|
|
20
|
+
if (outboundBundles.length === 0 && returnBundles.length === 0) {
|
|
21
|
+
return (_jsx("div", { className: "rounded-xl border border-dashed p-6 text-center text-muted-foreground text-sm", children: "This offer doesn't surface fare upgrade tiers." }));
|
|
22
|
+
}
|
|
23
|
+
const setPick = (passengerId, sliceIndex, bundleId) => {
|
|
24
|
+
const filtered = value.filter((p) => !(p.passengerId === passengerId && p.sliceIndex === sliceIndex));
|
|
25
|
+
if (bundleId) {
|
|
26
|
+
onChange([...filtered, { passengerId, sliceIndex, bundleId }]);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
onChange(filtered);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* "Same fare for all" handler — picks on behalf of every passenger on the
|
|
34
|
+
* given leg. A null bundleId clears all picks for that leg.
|
|
35
|
+
*/
|
|
36
|
+
const setLegPick = (sliceIndex, bundleId) => {
|
|
37
|
+
const filtered = value.filter((p) => p.sliceIndex !== sliceIndex);
|
|
38
|
+
if (!bundleId) {
|
|
39
|
+
onChange(filtered);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const additions = paxRows.map((p) => ({
|
|
43
|
+
passengerId: p.passengerId,
|
|
44
|
+
sliceIndex,
|
|
45
|
+
bundleId,
|
|
46
|
+
}));
|
|
47
|
+
onChange([...filtered, ...additions]);
|
|
48
|
+
};
|
|
49
|
+
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: "Upgrade your fare" }), _jsx("p", { className: "text-muted-foreground text-sm", children: "Add bag, seat picks, and flexibility per leg \u2014 or keep the base fare." })] }), isMultiPax && (_jsxs("div", { className: "flex shrink-0 items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "fare-same-for-all", checked: sameForAllPassengers, onCheckedChange: (v) => onSameForAllPassengersChange(!!v) }), _jsx("label", { htmlFor: "fare-same-for-all", className: "cursor-pointer", children: "Same fare for all passengers" })] }))] }), _jsx(FareLegSection, { label: "Outbound", bundles: outboundBundles, sliceIndex: 0, paxRows: paxRows, value: value, sameForAll: sameForAllPassengers || !isMultiPax, onSetPick: setPick, onSetLegPick: setLegPick }), returnOffer && returnBundles.length > 0 && (_jsx(FareLegSection, { label: "Return", bundles: returnBundles, sliceIndex: 1, paxRows: paxRows, value: value, sameForAll: sameForAllPassengers || !isMultiPax, onSetPick: setPick, onSetLegPick: setLegPick }))] }));
|
|
50
|
+
}
|
|
51
|
+
function FareLegSection({ label, bundles, sliceIndex, paxRows, value, sameForAll, onSetPick, onSetLegPick, }) {
|
|
52
|
+
// For "same for all" mode we treat the leg as having one selection — the
|
|
53
|
+
// bundleId common to every pax pick on this leg (null when split or none).
|
|
54
|
+
const legPick = (() => {
|
|
55
|
+
if (paxRows.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
const ids = paxRows.map((p) => value.find((v) => v.passengerId === p.passengerId && v.sliceIndex === sliceIndex)
|
|
58
|
+
?.bundleId ?? null);
|
|
59
|
+
if (ids.every((id) => id === ids[0]))
|
|
60
|
+
return ids[0] ?? null;
|
|
61
|
+
return null;
|
|
62
|
+
})();
|
|
63
|
+
const someoneSelected = value.some((v) => v.sliceIndex === sliceIndex);
|
|
64
|
+
return (_jsxs("section", { className: "flex flex-col gap-3", children: [_jsxs("header", { className: "flex items-baseline justify-between gap-2", children: [_jsx("h3", { className: "font-medium text-[11px] uppercase tracking-wider text-muted-foreground", children: label }), someoneSelected && (_jsx("button", { type: "button", onClick: () => onSetLegPick(sliceIndex, null), className: "text-muted-foreground text-xs hover:text-foreground", children: "Reset to Basic" }))] }), sameForAll ? (_jsx(BundleGrid, { bundles: bundles, selectedId: legPick, contextLabel: paxRows.length > 1 ? `Applies to all ${paxRows.length} passengers` : null, onPick: (id) => onSetLegPick(sliceIndex, id) })) : (_jsx("div", { className: "flex flex-col gap-4", children: paxRows.map((pax) => {
|
|
65
|
+
const pick = value.find((v) => v.passengerId === pax.passengerId && v.sliceIndex === sliceIndex);
|
|
66
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "font-medium text-sm", children: pax.label }), _jsx(BundleGrid, { bundles: bundles, selectedId: pick?.bundleId ?? null, onPick: (id) => onSetPick(pax.passengerId, sliceIndex, id) })] }, pax.passengerId));
|
|
67
|
+
}) }))] }));
|
|
68
|
+
}
|
|
69
|
+
function BundleGrid({ bundles, selectedId, contextLabel, onPick, }) {
|
|
70
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("div", { className: "grid gap-3 md:grid-cols-3", children: bundles.map((b) => (_jsx(BundleCard, { bundle: b, selected: selectedId === b.id, isBasicByDefault: selectedId == null && b.tier === "basic", onPick: () => onPick(b.tier === "basic" ? null : b.id) }, b.id))) }), contextLabel && _jsx("span", { className: "text-[11px] text-muted-foreground", children: contextLabel })] }));
|
|
71
|
+
}
|
|
72
|
+
function BundleCard({ bundle, selected, isBasicByDefault, onPick, }) {
|
|
73
|
+
const delta = Number(bundle.priceDelta.amount);
|
|
74
|
+
const deltaLabel = delta > 0 ? `+${formatMoney(bundle.priceDelta.amount, bundle.priceDelta.currency)}` : "Included";
|
|
75
|
+
const showActiveRing = selected || isBasicByDefault;
|
|
76
|
+
return (_jsxs("button", { type: "button", onClick: onPick, className: cn("relative flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors", showActiveRing
|
|
77
|
+
? "border-primary ring-2 ring-primary/20"
|
|
78
|
+
: "hover:border-primary/40 hover:bg-accent/30", bundle.recommended && !showActiveRing && "border-primary/40"), children: [bundle.recommended && (_jsx("span", { className: "-translate-y-1/2 absolute top-0 left-4 rounded-full bg-primary px-2 py-0.5 font-medium text-[9px] text-primary-foreground uppercase tracking-wider", children: "Recommended" })), _jsxs("div", { className: "flex items-baseline justify-between gap-2", children: [_jsxs("span", { className: "flex items-center gap-1.5 font-semibold text-sm", children: [_jsx(TierIcon, { tier: bundle.tier }), bundle.label] }), _jsx("span", { className: cn("font-medium text-xs", delta > 0 ? "text-foreground" : "text-emerald-600"), children: deltaLabel })] }), _jsxs("ul", { className: "flex flex-col gap-1.5", children: [_jsx(Inclusion, { ok: !!bundle.inclusions.cabinBag?.included, icon: _jsx(Briefcase, { className: "h-3.5 w-3.5" }), label: bundle.inclusions.cabinBag?.included
|
|
79
|
+
? `Cabin bag ${bundle.inclusions.cabinBag.weightKg ? `(${bundle.inclusions.cabinBag.weightKg} kg)` : ""}`.trim()
|
|
80
|
+
: "No cabin bag" }), _jsx(Inclusion, { ok: !!bundle.inclusions.checkedBag?.included, icon: _jsx(Luggage, { className: "h-3.5 w-3.5" }), label: bundle.inclusions.checkedBag?.included
|
|
81
|
+
? `Checked bag ${bundle.inclusions.checkedBag.weightKg
|
|
82
|
+
? `${bundle.inclusions.checkedBag.weightKg} kg`
|
|
83
|
+
: ""}${bundle.inclusions.checkedBag.pieces && bundle.inclusions.checkedBag.pieces > 1
|
|
84
|
+
? ` × ${bundle.inclusions.checkedBag.pieces}`
|
|
85
|
+
: ""}`.trim()
|
|
86
|
+
: "No checked bag" }), _jsx(Inclusion, { ok: bundle.inclusions.seatSelection !== "none" && bundle.inclusions.seatSelection != null, icon: _jsx(Sparkles, { className: "h-3.5 w-3.5" }), label: bundle.inclusions.seatSelection === "free"
|
|
87
|
+
? "Free seat selection"
|
|
88
|
+
: bundle.inclusions.seatSelection === "standard"
|
|
89
|
+
? "Standard seat selection"
|
|
90
|
+
: "No seat selection" }), _jsx(Inclusion, { ok: !!bundle.inclusions.priorityBoarding, label: "Priority boarding" }), _jsx(Inclusion, { ok: !!bundle.inclusions.loungeAccess, label: "Lounge access" }), _jsx(Inclusion, { ok: !!bundle.inclusions.changeable, label: bundle.inclusions.changeable ? "Free changes" : "Changes for a fee" }), _jsx(Inclusion, { ok: !!bundle.inclusions.refundable, label: bundle.inclusions.refundable ? "Refundable" : "Non-refundable" }), bundle.inclusions.notes?.map((n) => (_jsx(Inclusion, { ok: true, label: n }, n)))] }), selected && (_jsxs("span", { className: "mt-1 inline-flex items-center gap-1 self-start font-medium text-primary text-xs", children: [_jsx(Check, { className: "h-3 w-3" }), " Selected"] }))] }));
|
|
91
|
+
}
|
|
92
|
+
function Inclusion({ ok, icon, label }) {
|
|
93
|
+
return (_jsxs("li", { className: "flex items-center gap-2 text-xs", children: [ok ? (_jsx(Check, { className: "h-3.5 w-3.5 shrink-0 text-emerald-600" })) : (_jsx(X, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground/60" })), _jsxs("span", { className: "flex items-center gap-1.5 text-foreground", children: [icon, _jsx("span", { className: cn(!ok && "text-muted-foreground"), children: label })] })] }));
|
|
94
|
+
}
|
|
95
|
+
function TierIcon({ tier }) {
|
|
96
|
+
switch (tier) {
|
|
97
|
+
case "plus":
|
|
98
|
+
case "premium":
|
|
99
|
+
return _jsx(Crown, { className: "h-3.5 w-3.5 text-amber-500" });
|
|
100
|
+
case "standard":
|
|
101
|
+
return _jsx(Sparkles, { className: "h-3.5 w-3.5 text-primary" });
|
|
102
|
+
default:
|
|
103
|
+
return _jsx(Briefcase, { className: "h-3.5 w-3.5 text-muted-foreground" });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function buildPassengerRows(passengers, counts) {
|
|
107
|
+
if (passengers.length > 0) {
|
|
108
|
+
return passengers.map((p, idx) => ({
|
|
109
|
+
passengerId: p.passengerId,
|
|
110
|
+
label: nameOrFallback(p, idx),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
const out = [];
|
|
114
|
+
for (let i = 1; i <= counts.adults; i++) {
|
|
115
|
+
out.push({ passengerId: `pax_adult_${i}`, label: `Adult ${i}` });
|
|
116
|
+
}
|
|
117
|
+
for (let i = 1; i <= (counts.children ?? 0); i++) {
|
|
118
|
+
out.push({ passengerId: `pax_child_${i}`, label: `Child ${i}` });
|
|
119
|
+
}
|
|
120
|
+
for (let i = 1; i <= (counts.infants ?? 0); i++) {
|
|
121
|
+
out.push({ passengerId: `pax_infant_${i}`, label: `Infant ${i}` });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function nameOrFallback(p, idx) {
|
|
126
|
+
const full = `${p.firstName} ${p.lastName}`.trim();
|
|
127
|
+
if (full)
|
|
128
|
+
return full;
|
|
129
|
+
const cap = p.type[0]?.toUpperCase() + p.type.slice(1);
|
|
130
|
+
return `${cap} ${idx + 1}`;
|
|
131
|
+
}
|
|
132
|
+
function formatMoney(amount, currency) {
|
|
133
|
+
const n = Number(amount);
|
|
134
|
+
if (!Number.isFinite(n))
|
|
135
|
+
return `${amount} ${currency}`;
|
|
136
|
+
return new Intl.NumberFormat(undefined, {
|
|
137
|
+
style: "currency",
|
|
138
|
+
currency,
|
|
139
|
+
maximumFractionDigits: 0,
|
|
140
|
+
}).format(n);
|
|
141
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FlightOffer } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export interface FlightFiltersValue {
|
|
3
|
+
/** Selected carrier IATA codes (empty = no carrier filter). */
|
|
4
|
+
carriers: string[];
|
|
5
|
+
/** Max stops on any single itinerary. `null` = no cap. */
|
|
6
|
+
maxStops: number | null;
|
|
7
|
+
/** Inclusive price ceiling (in offer's currency). `null` = no cap. */
|
|
8
|
+
maxPrice: number | null;
|
|
9
|
+
}
|
|
10
|
+
export declare const EMPTY_FLIGHT_FILTERS: FlightFiltersValue;
|
|
11
|
+
export interface FlightFiltersBarProps {
|
|
12
|
+
value: FlightFiltersValue;
|
|
13
|
+
onChange: (next: FlightFiltersValue) => void;
|
|
14
|
+
/** Live offer set — used to derive carrier facet buckets and the price/stops range. */
|
|
15
|
+
offers: FlightOffer[];
|
|
16
|
+
carrierName?: (iataCode: string) => string | undefined;
|
|
17
|
+
}
|
|
18
|
+
export declare function FlightFiltersBar({ value, onChange, offers, carrierName }: FlightFiltersBarProps): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
//# sourceMappingURL=flight-filters-bar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-filters-bar.d.ts","sourceRoot":"","sources":["../../src/components/flight-filters-bar.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAmBnE,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,sEAAsE;IACtE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB;AAED,eAAO,MAAM,oBAAoB,EAAE,kBAIlC,CAAA;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,kBAAkB,CAAA;IACzB,QAAQ,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAA;IAC5C,uFAAuF;IACvF,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,WAAW,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;CACvD;AAED,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,qBAAqB,2CA+C/F"}
|
|
@@ -0,0 +1,90 @@
|
|
|
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 { Checkbox } from "@voyantjs/ui/components/checkbox";
|
|
5
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@voyantjs/ui/components/command";
|
|
6
|
+
import { Input } from "@voyantjs/ui/components/input";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@voyantjs/ui/components/popover";
|
|
8
|
+
import { PlusCircle, X } from "lucide-react";
|
|
9
|
+
import { AirlineLogo } from "./airline-logo";
|
|
10
|
+
export const EMPTY_FLIGHT_FILTERS = {
|
|
11
|
+
carriers: [],
|
|
12
|
+
maxStops: null,
|
|
13
|
+
maxPrice: null,
|
|
14
|
+
};
|
|
15
|
+
export function FlightFiltersBar({ value, onChange, offers, carrierName }) {
|
|
16
|
+
const carrierBuckets = deriveCarrierBuckets(offers);
|
|
17
|
+
const stopsBuckets = deriveStopsBuckets(offers);
|
|
18
|
+
const hasSelections = value.carriers.length > 0 || value.maxStops != null || value.maxPrice != null;
|
|
19
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(CarrierFilter, { buckets: carrierBuckets, selected: value.carriers, carrierName: carrierName, onToggle: (code) => onChange({
|
|
20
|
+
...value,
|
|
21
|
+
carriers: value.carriers.includes(code)
|
|
22
|
+
? value.carriers.filter((c) => c !== code)
|
|
23
|
+
: [...value.carriers, code],
|
|
24
|
+
}), onClear: () => onChange({ ...value, carriers: [] }) }), _jsx(StopsFilter, { buckets: stopsBuckets, selected: value.maxStops, onSelect: (maxStops) => onChange({ ...value, maxStops }) }), _jsx(PriceFilter, { value: value.maxPrice, onChange: (maxPrice) => onChange({ ...value, maxPrice }) }), hasSelections && (_jsxs(Button, { variant: "ghost", size: "sm", className: "h-8 px-2 text-muted-foreground hover:text-foreground", onClick: () => onChange(EMPTY_FLIGHT_FILTERS), children: [_jsx(X, { className: "mr-1 h-3.5 w-3.5" }), "Clear all"] }))] }));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Shared inline trigger contents for the three filter popovers. Renders
|
|
28
|
+
* inside `<PopoverTrigger render={<Button .../>}>` as the button's
|
|
29
|
+
* children — base-ui merges its onClick / aria-expanded onto the Button
|
|
30
|
+
* directly. (Wrapping these in a separate component breaks the click flow
|
|
31
|
+
* because base-ui's prop-merge can't see through a custom wrapper.)
|
|
32
|
+
*/
|
|
33
|
+
function TriggerContents({ label, count, preview, }) {
|
|
34
|
+
return (_jsxs(_Fragment, { children: [_jsx(PlusCircle, { className: "h-3.5 w-3.5" }), _jsx("span", { children: label }), preview, count != null && count > 0 && (_jsx("span", { className: "-mr-0.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-medium tabular-nums text-primary-foreground", children: count }))] }));
|
|
35
|
+
}
|
|
36
|
+
const TRIGGER_CLASS = "h-8 gap-2 border-dashed";
|
|
37
|
+
function CarrierFilter({ buckets, selected, carrierName, onToggle, onClear, }) {
|
|
38
|
+
if (buckets.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
const selectedSet = new Set(selected);
|
|
41
|
+
return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", className: TRIGGER_CLASS }), children: _jsx(TriggerContents, { label: "Airlines", count: selected.length }) }), _jsx(PopoverContent, { className: "w-[260px] p-0", align: "start", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Filter airlines\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "No airlines." }), _jsx(CommandGroup, { children: buckets.map((b) => {
|
|
42
|
+
const isSelected = selectedSet.has(b.iataCode);
|
|
43
|
+
return (_jsxs(CommandItem, { onSelect: () => onToggle(b.iataCode), children: [_jsx(Checkbox, { checked: isSelected, tabIndex: -1, "aria-hidden": true, className: "mr-2 pointer-events-none" }), _jsx(AirlineLogo, { iataCode: b.iataCode, name: carrierName?.(b.iataCode), size: 20, className: "mr-2" }), _jsx("span", { className: "flex-1 truncate", children: carrierName?.(b.iataCode) ?? b.iataCode }), _jsx("span", { className: "ml-2 text-xs text-muted-foreground", children: b.count })] }, b.iataCode));
|
|
44
|
+
}) }), selected.length > 0 && (_jsxs(_Fragment, { children: [_jsx(CommandSeparator, {}), _jsx(CommandGroup, { children: _jsx(CommandItem, { onSelect: onClear, className: "justify-center text-center text-muted-foreground", children: "Clear filter" }) })] }))] })] }) })] }));
|
|
45
|
+
}
|
|
46
|
+
function deriveCarrierBuckets(offers) {
|
|
47
|
+
const counts = new Map();
|
|
48
|
+
for (const offer of offers) {
|
|
49
|
+
const carriers = new Set();
|
|
50
|
+
for (const itin of offer.itineraries) {
|
|
51
|
+
for (const seg of itin.segments) {
|
|
52
|
+
carriers.add(seg.carrierCode);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const c of carriers)
|
|
56
|
+
counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
return Array.from(counts.entries())
|
|
59
|
+
.map(([iataCode, count]) => ({ iataCode, count }))
|
|
60
|
+
.sort((a, b) => b.count - a.count);
|
|
61
|
+
}
|
|
62
|
+
function StopsFilter({ buckets, selected, onSelect, }) {
|
|
63
|
+
if (buckets.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
const preview = selected != null ? (_jsx("span", { className: "text-muted-foreground", children: selected === 0 ? "· Nonstop" : `· ≤ ${selected}` })) : null;
|
|
66
|
+
return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", className: TRIGGER_CLASS }), children: _jsx(TriggerContents, { label: "Stops", preview: preview }) }), _jsx(PopoverContent, { className: "w-[200px] p-0", align: "start", children: _jsx(Command, { children: _jsxs(CommandList, { children: [_jsx(CommandGroup, { children: buckets.map((b) => {
|
|
67
|
+
const isSelected = selected === b.stops;
|
|
68
|
+
return (_jsxs(CommandItem, { onSelect: () => onSelect(b.stops), children: [_jsx(Checkbox, { checked: isSelected, tabIndex: -1, "aria-hidden": true, className: "mr-2 pointer-events-none" }), _jsx("span", { className: "flex-1", children: b.stops === 0 ? "Nonstop" : `Up to ${b.stops} stop${b.stops > 1 ? "s" : ""}` }), _jsx("span", { className: "ml-2 text-xs text-muted-foreground", children: b.count })] }, b.stops));
|
|
69
|
+
}) }), selected != null && (_jsxs(_Fragment, { children: [_jsx(CommandSeparator, {}), _jsx(CommandGroup, { children: _jsx(CommandItem, { onSelect: () => onSelect(null), className: "justify-center text-center text-muted-foreground", children: "Clear filter" }) })] }))] }) }) })] }));
|
|
70
|
+
}
|
|
71
|
+
function deriveStopsBuckets(offers) {
|
|
72
|
+
const counts = new Map();
|
|
73
|
+
for (const offer of offers) {
|
|
74
|
+
const maxStops = Math.max(0, ...offer.itineraries.map((i) => i.segments.length - 1));
|
|
75
|
+
counts.set(maxStops, (counts.get(maxStops) ?? 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
return Array.from(counts.entries())
|
|
78
|
+
.map(([stops, count]) => ({ stops, count }))
|
|
79
|
+
.sort((a, b) => a.stops - b.stops);
|
|
80
|
+
}
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Price
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
function PriceFilter({ value, onChange, }) {
|
|
85
|
+
const preview = value != null ? _jsxs("span", { className: "text-muted-foreground", children: ["\u00B7 \u2264 ", value] }) : null;
|
|
86
|
+
return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", className: TRIGGER_CLASS }), children: _jsx(TriggerContents, { label: "Price", preview: preview }) }), _jsx(PopoverContent, { className: "w-[240px] p-3", align: "start", children: _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "Maximum price" }), _jsx(Input, { type: "number", inputMode: "decimal", min: 0, placeholder: "No cap", defaultValue: value ?? "", className: "h-8", onBlur: (e) => {
|
|
87
|
+
const n = Number(e.target.value);
|
|
88
|
+
onChange(Number.isFinite(n) && n > 0 ? n : null);
|
|
89
|
+
} }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => onChange(null), disabled: value == null, className: "self-start", children: "Clear" })] }) })] }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Itinerary } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export interface FlightItineraryProps {
|
|
3
|
+
itinerary: Itinerary;
|
|
4
|
+
/** Optional label shown above the segment list (e.g. "Outbound", "Return"). */
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Optional sub-label, typically the dated city pair: "BUH → LON · Mon, 13 Jul". */
|
|
7
|
+
sublabel?: string;
|
|
8
|
+
carrierName?: (iataCode: string) => string | undefined;
|
|
9
|
+
airportName?: (iataCode: string) => string | undefined;
|
|
10
|
+
aircraftName?: (iataCode: string) => string | undefined;
|
|
11
|
+
/** Compact rendering for use inside the ledger / right rail. */
|
|
12
|
+
compact?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Carrier-aware itinerary renderer. One itinerary = one direction of travel
|
|
17
|
+
* (outbound, return, or one leg of an open-jaw). Surfaces:
|
|
18
|
+
* - per-segment carrier + flight number
|
|
19
|
+
* - operating-vs-marketing carrier ("Operated by …") for codeshares
|
|
20
|
+
* - layover dwell time chips between segments
|
|
21
|
+
* - aircraft per segment
|
|
22
|
+
* - total journey duration
|
|
23
|
+
*
|
|
24
|
+
* `compact` strips the per-segment cards down to a single timeline row —
|
|
25
|
+
* suitable for the booking ledger.
|
|
26
|
+
*/
|
|
27
|
+
export declare function FlightItinerary({ itinerary, label, sublabel, carrierName, airportName, aircraftName, compact, className, }: FlightItineraryProps): import("react/jsx-runtime").JSX.Element | null;
|
|
28
|
+
//# sourceMappingURL=flight-itinerary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-itinerary.d.ts","sourceRoot":"","sources":["../../src/components/flight-itinerary.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,kCAAkC,CAAA;AAOhF,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,SAAS,CAAA;IACpB,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,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,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;IACvD,gEAAgE;IAChE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,EAC9B,SAAS,EACT,KAAK,EACL,QAAQ,EACR,WAAW,EACX,WAAW,EACX,YAAY,EACZ,OAAO,EACP,SAAS,GACV,EAAE,oBAAoB,kDA4EtB"}
|
|
@@ -0,0 +1,90 @@
|
|
|
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 { cn } from "@voyantjs/ui/lib/utils";
|
|
5
|
+
import { Plane } from "lucide-react";
|
|
6
|
+
import { AirlineLogo } from "./airline-logo";
|
|
7
|
+
/**
|
|
8
|
+
* Carrier-aware itinerary renderer. One itinerary = one direction of travel
|
|
9
|
+
* (outbound, return, or one leg of an open-jaw). Surfaces:
|
|
10
|
+
* - per-segment carrier + flight number
|
|
11
|
+
* - operating-vs-marketing carrier ("Operated by …") for codeshares
|
|
12
|
+
* - layover dwell time chips between segments
|
|
13
|
+
* - aircraft per segment
|
|
14
|
+
* - total journey duration
|
|
15
|
+
*
|
|
16
|
+
* `compact` strips the per-segment cards down to a single timeline row —
|
|
17
|
+
* suitable for the booking ledger.
|
|
18
|
+
*/
|
|
19
|
+
export function FlightItinerary({ itinerary, label, sublabel, carrierName, airportName, aircraftName, compact, className, }) {
|
|
20
|
+
const segs = itinerary.segments;
|
|
21
|
+
const first = segs[0];
|
|
22
|
+
const last = segs[segs.length - 1];
|
|
23
|
+
if (!first || !last)
|
|
24
|
+
return null;
|
|
25
|
+
const stops = segs.length - 1;
|
|
26
|
+
const totalDuration = itinerary.duration ?? deriveDuration(first, last);
|
|
27
|
+
const carriers = Array.from(new Set(segs.map((s) => s.carrierCode)));
|
|
28
|
+
if (compact) {
|
|
29
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-1.5", className), children: [(label || sublabel) && (_jsxs("div", { className: "flex items-baseline justify-between gap-2", children: [label && (_jsx("span", { className: "font-medium text-[11px] uppercase tracking-wider text-muted-foreground", children: label })), sublabel && _jsx("span", { className: "text-[11px] text-muted-foreground", children: sublabel })] })), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex shrink-0 items-center -space-x-1", children: carriers.map((code) => (_jsx(AirlineLogo, { iataCode: code, name: carrierName?.(code), size: 20 }, code))) }), _jsxs("span", { className: "font-mono text-xs text-foreground", children: [first.departure.iataCode, " \u2192 ", last.arrival.iataCode] }), _jsxs("span", { className: "text-[11px] text-muted-foreground", children: [formatTime(first.departure.at), " \u2013 ", formatTime(last.arrival.at)] }), _jsxs("span", { className: "ml-auto text-[11px] text-muted-foreground", children: [stops === 0 ? "Nonstop" : `${stops} stop${stops > 1 ? "s" : ""}`, totalDuration && ` · ${formatDuration(totalDuration)}`] })] })] }));
|
|
30
|
+
}
|
|
31
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-3", className), children: [(label || sublabel) && (_jsxs("div", { className: "flex items-baseline justify-between gap-2", children: [label && (_jsx("h4", { className: "font-medium text-[11px] uppercase tracking-wider text-muted-foreground", children: label })), (sublabel || totalDuration) && (_jsxs("span", { className: "text-[11px] text-muted-foreground", children: [sublabel, sublabel && totalDuration && " · ", totalDuration && `Total ${formatDuration(totalDuration)}`] }))] })), _jsx("div", { className: "flex flex-col", children: segs.map((seg, idx) => (_jsx(SegmentBlock, { segment: seg, carrierName: carrierName, airportName: airportName, aircraftName: aircraftName, layoverBefore: idx > 0 ? layoverBetween(segs[idx - 1], seg) : null }, seg.segmentId))) })] }));
|
|
32
|
+
}
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
function SegmentBlock({ segment, carrierName, airportName, aircraftName, layoverBefore, }) {
|
|
35
|
+
const isCodeshare = segment.operatingCarrierCode != null && segment.operatingCarrierCode !== segment.carrierCode;
|
|
36
|
+
return (_jsxs(_Fragment, { children: [layoverBefore && (_jsxs("div", { className: "my-1 flex items-center gap-2 px-2 text-xs text-muted-foreground", children: [_jsx("div", { className: "h-px flex-1 bg-border" }), _jsxs("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[11px]", children: ["Layover \u00B7 ", layoverBefore.dwell, " in ", layoverBefore.airport] }), _jsx("div", { className: "h-px flex-1 bg-border" })] })), _jsxs("div", { className: "rounded-lg border bg-card p-3", children: [_jsxs("div", { className: "mb-2 flex flex-wrap items-center gap-2", children: [_jsx(AirlineLogo, { iataCode: segment.carrierCode, name: carrierName?.(segment.carrierCode), size: 24 }), _jsx("span", { className: "font-medium text-sm", children: carrierName?.(segment.carrierCode) ?? segment.carrierCode }), _jsxs("span", { className: "font-mono text-muted-foreground text-xs", children: [segment.carrierCode, segment.flightNumber] }), isCodeshare && (_jsxs(Badge, { variant: "secondary", className: "text-[10px] uppercase tracking-wide", children: ["Operated by", " ", carrierName?.(segment.operatingCarrierCode ?? "") ?? segment.operatingCarrierCode] })), _jsx(Badge, { variant: "outline", className: "ml-auto capitalize", children: segment.cabin.replace("_", " ") })] }), _jsxs("div", { className: "grid grid-cols-[1fr_auto_1fr] items-center gap-3", children: [_jsx(Endpoint, { at: segment.departure.at, iata: segment.departure.iataCode, terminal: segment.departure.terminal, airportName: airportName?.(segment.departure.iataCode) }), _jsxs("div", { className: "flex flex-col items-center gap-1 text-muted-foreground text-xs", children: [_jsx(Plane, { className: "h-3.5 w-3.5" }), segment.duration && _jsx("span", { children: formatDuration(segment.duration) })] }), _jsx(Endpoint, { at: segment.arrival.at, iata: segment.arrival.iataCode, terminal: segment.arrival.terminal, airportName: airportName?.(segment.arrival.iataCode), align: "end" })] }), segment.aircraft && (_jsxs("div", { className: "mt-2 text-[11px] text-muted-foreground", children: ["Aircraft:", " ", _jsx("span", { className: "text-foreground", children: aircraftName?.(segment.aircraft) ?? segment.aircraft })] }))] })] }));
|
|
37
|
+
}
|
|
38
|
+
function Endpoint({ at, iata, terminal, airportName, align = "start", }) {
|
|
39
|
+
return (_jsxs("div", { className: cn("flex flex-col leading-tight", align === "end" ? "items-end" : "items-start"), children: [_jsx("span", { className: "font-semibold text-lg tabular-nums", children: formatTime(at) }), _jsx("span", { className: "font-mono text-muted-foreground text-xs", children: iata }), airportName && _jsx("span", { className: "text-[11px] text-muted-foreground", children: airportName }), terminal && _jsxs("span", { className: "text-[10px] text-muted-foreground", children: ["Terminal ", terminal] }), _jsx("span", { className: "mt-0.5 text-[10px] text-muted-foreground", children: formatDate(at) })] }));
|
|
40
|
+
}
|
|
41
|
+
// ── Formatters ───────────────────────────────────────────────────────────────
|
|
42
|
+
function formatTime(iso) {
|
|
43
|
+
const d = new Date(iso);
|
|
44
|
+
if (Number.isNaN(d.getTime()))
|
|
45
|
+
return iso;
|
|
46
|
+
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(d);
|
|
47
|
+
}
|
|
48
|
+
function formatDate(iso) {
|
|
49
|
+
const d = new Date(iso);
|
|
50
|
+
if (Number.isNaN(d.getTime()))
|
|
51
|
+
return iso;
|
|
52
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
53
|
+
weekday: "short",
|
|
54
|
+
day: "numeric",
|
|
55
|
+
month: "short",
|
|
56
|
+
}).format(d);
|
|
57
|
+
}
|
|
58
|
+
function formatDuration(iso) {
|
|
59
|
+
if (!iso)
|
|
60
|
+
return "";
|
|
61
|
+
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?$/.exec(iso);
|
|
62
|
+
if (!m)
|
|
63
|
+
return iso;
|
|
64
|
+
const h = m[1] ? `${m[1]}h` : "";
|
|
65
|
+
const min = m[2] ? `${m[2]}m` : "";
|
|
66
|
+
return [h, min].filter(Boolean).join(" ") || iso;
|
|
67
|
+
}
|
|
68
|
+
function layoverBetween(prev, next) {
|
|
69
|
+
if (!prev)
|
|
70
|
+
return null;
|
|
71
|
+
const a = new Date(prev.arrival.at).getTime();
|
|
72
|
+
const b = new Date(next.departure.at).getTime();
|
|
73
|
+
if (Number.isNaN(a) || Number.isNaN(b) || b <= a)
|
|
74
|
+
return null;
|
|
75
|
+
const minutes = Math.round((b - a) / 60000);
|
|
76
|
+
const h = Math.floor(minutes / 60);
|
|
77
|
+
const m = minutes % 60;
|
|
78
|
+
const dwell = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean).join(" ") || `${minutes}m`;
|
|
79
|
+
return { airport: prev.arrival.iataCode, dwell };
|
|
80
|
+
}
|
|
81
|
+
function deriveDuration(first, last) {
|
|
82
|
+
const a = new Date(first.departure.at).getTime();
|
|
83
|
+
const b = new Date(last.arrival.at).getTime();
|
|
84
|
+
if (Number.isNaN(a) || Number.isNaN(b) || b <= a)
|
|
85
|
+
return undefined;
|
|
86
|
+
const minutes = Math.round((b - a) / 60000);
|
|
87
|
+
const h = Math.floor(minutes / 60);
|
|
88
|
+
const m = minutes % 60;
|
|
89
|
+
return `PT${h}H${m}M`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FlightOffer } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export interface FlightOfferDetailProps {
|
|
3
|
+
offer: FlightOffer;
|
|
4
|
+
/** Resolves IATA carrier code → human-readable name. */
|
|
5
|
+
carrierName?: (iataCode: string) => string | undefined;
|
|
6
|
+
/** Resolves IATA airport code → "City Name (IATA)" or similar. */
|
|
7
|
+
airportName?: (iataCode: string) => string | undefined;
|
|
8
|
+
/** Resolves IATA aircraft code → "Boeing 737-800" or similar. */
|
|
9
|
+
aircraftName?: (iataCode: string) => string | undefined;
|
|
10
|
+
/** Override per-itinerary labels (defaults to "Outbound" / "Return" / "Leg N"). */
|
|
11
|
+
itineraryLabels?: string[];
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Full-fidelity flight offer view for the detail sheet. Composes the shared
|
|
16
|
+
* `FlightItinerary` renderer for each leg, then a fare breakdown + offer
|
|
17
|
+
* metadata. Codeshare segments and layover dwell times are surfaced by the
|
|
18
|
+
* itinerary component itself.
|
|
19
|
+
*/
|
|
20
|
+
export declare function FlightOfferDetail({ offer, carrierName, airportName, aircraftName, itineraryLabels, className, }: FlightOfferDetailProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
//# sourceMappingURL=flight-offer-detail.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-offer-detail.d.ts","sourceRoot":"","sources":["../../src/components/flight-offer-detail.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAiB,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAOlF,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,WAAW,CAAA;IAClB,wDAAwD;IACxD,WAAW,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;IACtD,kEAAkE;IAClE,WAAW,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;IACtD,iEAAiE;IACjE,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;IACvD,mFAAmF;IACnF,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,WAAW,EACX,WAAW,EACX,YAAY,EACZ,eAAe,EACf,SAAS,GACV,EAAE,sBAAsB,2CAwDxB"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Badge } from "@voyantjs/ui/components/badge";
|
|
4
|
+
import { Separator } from "@voyantjs/ui/components/separator";
|
|
5
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
6
|
+
import { FlightItinerary } from "./flight-itinerary";
|
|
7
|
+
/**
|
|
8
|
+
* Full-fidelity flight offer view for the detail sheet. Composes the shared
|
|
9
|
+
* `FlightItinerary` renderer for each leg, then a fare breakdown + offer
|
|
10
|
+
* metadata. Codeshare segments and layover dwell times are surfaced by the
|
|
11
|
+
* itinerary component itself.
|
|
12
|
+
*/
|
|
13
|
+
export function FlightOfferDetail({ offer, carrierName, airportName, aircraftName, itineraryLabels, className, }) {
|
|
14
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-6", className), children: [_jsx("section", { className: "flex flex-col gap-5", children: offer.itineraries.map((itin, i) => (_jsx(FlightItinerary
|
|
15
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: itineraries are positional (outbound/return)
|
|
16
|
+
, { itinerary: itin, label: itineraryLabels?.[i] ?? defaultItineraryLabel(i, offer.itineraries.length), carrierName: carrierName, airportName: airportName, aircraftName: aircraftName }, i))) }), _jsxs("section", { className: "flex flex-col gap-2", children: [_jsx("h4", { className: "font-medium text-[11px] uppercase tracking-wider text-muted-foreground", children: "Fare breakdown" }), _jsxs("div", { className: "overflow-hidden rounded-lg border", children: [offer.fareBreakdowns.map((b, i) => (_jsx(FareRow, { breakdown: b }, i))), _jsx(Separator, {}), _jsxs("div", { className: "flex items-center justify-between bg-muted/30 px-4 py-3", children: [_jsx("span", { className: "font-medium text-sm", children: "Total" }), _jsx("span", { className: "font-semibold text-base tabular-nums", children: formatMoney(offer.totalPrice.amount, offer.totalPrice.currency) })] })] })] }), (offer.validatingCarrier ||
|
|
17
|
+
offer.expiresAt ||
|
|
18
|
+
offer.lastTicketingDate ||
|
|
19
|
+
offer.instantTicketing) && (_jsxs("section", { className: "flex flex-wrap items-center gap-2 text-muted-foreground text-xs", children: [offer.validatingCarrier && (_jsxs("span", { children: ["Validating carrier:", " ", _jsx("span", { className: "font-mono text-foreground", children: offer.validatingCarrier })] })), offer.expiresAt && _jsxs("span", { children: ["\u00B7 Expires ", formatDateTime(offer.expiresAt)] }), offer.lastTicketingDate && (_jsxs("span", { children: ["\u00B7 Last ticketing ", formatDate(offer.lastTicketingDate)] })), offer.instantTicketing && _jsx(Badge, { variant: "secondary", children: "Instant ticketing" })] }))] }));
|
|
20
|
+
}
|
|
21
|
+
function defaultItineraryLabel(idx, total) {
|
|
22
|
+
if (total === 1)
|
|
23
|
+
return "Itinerary";
|
|
24
|
+
if (total === 2)
|
|
25
|
+
return idx === 0 ? "Outbound" : "Return";
|
|
26
|
+
return `Leg ${idx + 1}`;
|
|
27
|
+
}
|
|
28
|
+
function FareRow({ breakdown }) {
|
|
29
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-4 px-4 py-2.5 text-sm", children: [_jsxs("div", { className: "flex flex-col leading-tight", children: [_jsxs("span", { className: "capitalize", children: [breakdown.passengerCount, "\u00D7 ", breakdown.passengerType] }), breakdown.fareFamily && (_jsx("span", { className: "text-muted-foreground text-xs", children: breakdown.fareFamily }))] }), _jsxs("div", { className: "flex items-center gap-4 text-muted-foreground text-xs tabular-nums", children: [_jsxs("span", { children: ["Base ", formatMoney(breakdown.baseFare.amount, breakdown.baseFare.currency)] }), _jsxs("span", { children: ["Tax ", formatMoney(breakdown.taxes.amount, breakdown.taxes.currency)] }), _jsx("span", { className: "font-medium text-foreground text-sm", children: formatMoney(breakdown.total.amount, breakdown.total.currency) })] })] }));
|
|
30
|
+
}
|
|
31
|
+
function formatMoney(amount, currency) {
|
|
32
|
+
const n = Number(amount);
|
|
33
|
+
if (!Number.isFinite(n))
|
|
34
|
+
return `${amount} ${currency}`;
|
|
35
|
+
return new Intl.NumberFormat(undefined, {
|
|
36
|
+
style: "currency",
|
|
37
|
+
currency,
|
|
38
|
+
maximumFractionDigits: 0,
|
|
39
|
+
}).format(n);
|
|
40
|
+
}
|
|
41
|
+
function formatDate(iso) {
|
|
42
|
+
const d = new Date(iso);
|
|
43
|
+
if (Number.isNaN(d.getTime()))
|
|
44
|
+
return iso;
|
|
45
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
46
|
+
weekday: "short",
|
|
47
|
+
day: "numeric",
|
|
48
|
+
month: "short",
|
|
49
|
+
}).format(d);
|
|
50
|
+
}
|
|
51
|
+
function formatDateTime(iso) {
|
|
52
|
+
const d = new Date(iso);
|
|
53
|
+
if (Number.isNaN(d.getTime()))
|
|
54
|
+
return iso;
|
|
55
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
56
|
+
day: "numeric",
|
|
57
|
+
month: "short",
|
|
58
|
+
hour: "2-digit",
|
|
59
|
+
minute: "2-digit",
|
|
60
|
+
}).format(d);
|
|
61
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FlightOffer } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export interface FlightOfferRowProps {
|
|
3
|
+
offer: FlightOffer;
|
|
4
|
+
/** Click handler — typically opens the detail sheet. */
|
|
5
|
+
onClick?: (offer: FlightOffer) => void;
|
|
6
|
+
/** "Select" CTA — when set, renders a primary button alongside the price. */
|
|
7
|
+
onSelect?: (offer: FlightOffer) => void;
|
|
8
|
+
/** Customize the select CTA label. Defaults to "Select". */
|
|
9
|
+
selectLabel?: string;
|
|
10
|
+
/** Optional carrier name resolver — used for the logo `alt` text. */
|
|
11
|
+
carrierName?: (iataCode: string) => string | undefined;
|
|
12
|
+
/** Highlight ring (e.g. when this offer is currently picked). */
|
|
13
|
+
selected?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One row in the search-results list. Lays out each itinerary on its own
|
|
18
|
+
* line: carriers · departure time · journey · arrival time · stops ·
|
|
19
|
+
* duration. Total price sits on the right with an optional "Select" CTA.
|
|
20
|
+
*
|
|
21
|
+
* For per-leg searches, each offer carries one itinerary; for combined
|
|
22
|
+
* round-trip searches it carries two. The renderer handles both shapes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function FlightOfferRow({ offer, onClick, onSelect, selectLabel, carrierName, selected, className, }: FlightOfferRowProps): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
//# sourceMappingURL=flight-offer-row.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-offer-row.d.ts","sourceRoot":"","sources":["../../src/components/flight-offer-row.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAa,MAAM,kCAAkC,CAAA;AAO9E,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,WAAW,CAAA;IAClB,wDAAwD;IACxD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACvC,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,qEAAqE;IACrE,WAAW,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;IACtD,iEAAiE;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,OAAO,EACP,QAAQ,EACR,WAAsB,EACtB,WAAW,EACX,QAAQ,EACR,SAAS,GACV,EAAE,mBAAmB,2CAwCrB"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Badge } from "@voyantjs/ui/components/badge";
|
|
4
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
5
|
+
import { Plane } from "lucide-react";
|
|
6
|
+
import { AirlineLogo } from "./airline-logo";
|
|
7
|
+
/**
|
|
8
|
+
* One row in the search-results list. Lays out each itinerary on its own
|
|
9
|
+
* line: carriers · departure time · journey · arrival time · stops ·
|
|
10
|
+
* duration. Total price sits on the right with an optional "Select" CTA.
|
|
11
|
+
*
|
|
12
|
+
* For per-leg searches, each offer carries one itinerary; for combined
|
|
13
|
+
* round-trip searches it carries two. The renderer handles both shapes.
|
|
14
|
+
*/
|
|
15
|
+
export function FlightOfferRow({ offer, onClick, onSelect, selectLabel = "Select", carrierName, selected, className, }) {
|
|
16
|
+
const interactive = !!onClick;
|
|
17
|
+
const Container = interactive ? "button" : "div";
|
|
18
|
+
return (_jsxs(Container, { type: interactive ? "button" : undefined, onClick: onClick ? () => onClick(offer) : undefined, className: cn("flex w-full items-stretch gap-4 rounded-lg border bg-card p-4 text-left shadow-sm transition-colors", interactive && "hover:border-primary/40 hover:bg-accent/30", selected && "border-primary ring-1 ring-primary/40", className), children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-3", children: offer.itineraries.map((itin, i) => (_jsx(ItineraryRow, { itinerary: itin, carrierName: carrierName }, i))) }), _jsxs("div", { className: "flex shrink-0 flex-col items-end justify-center gap-2 border-l pl-4", children: [_jsx("div", { className: "font-semibold text-2xl tabular-nums", children: formatMoney(offer.totalPrice.amount, offer.totalPrice.currency) }), _jsx(PriceFootnote, { offer: offer }), onSelect && (_jsx("button", { type: "button", onClick: (e) => {
|
|
19
|
+
e.stopPropagation();
|
|
20
|
+
onSelect(offer);
|
|
21
|
+
}, className: "mt-1 inline-flex h-8 items-center justify-center rounded-md bg-primary px-3 font-medium text-primary-foreground text-xs hover:bg-primary/90", children: selectLabel }))] })] }));
|
|
22
|
+
}
|
|
23
|
+
function PriceFootnote({ offer }) {
|
|
24
|
+
const totalPax = offer.fareBreakdowns.reduce((n, b) => n + b.passengerCount, 0);
|
|
25
|
+
const adult = offer.fareBreakdowns.find((b) => b.passengerType === "adult");
|
|
26
|
+
if (totalPax <= 1) {
|
|
27
|
+
return _jsx("div", { className: "text-muted-foreground text-xs", children: "total" });
|
|
28
|
+
}
|
|
29
|
+
return (_jsxs("div", { className: "flex flex-col items-end gap-0.5 text-muted-foreground text-xs", children: [_jsxs("span", { children: ["total \u00B7 ", totalPax, " pax"] }), adult && (_jsxs("span", { children: [formatMoney(adult.total.amount, adult.total.currency), _jsx("span", { className: "ml-0.5 text-muted-foreground/70", children: "/adult" })] }))] }));
|
|
30
|
+
}
|
|
31
|
+
function ItineraryRow({ itinerary, carrierName, }) {
|
|
32
|
+
const segs = itinerary.segments;
|
|
33
|
+
const first = segs[0];
|
|
34
|
+
const last = segs[segs.length - 1];
|
|
35
|
+
if (!first || !last)
|
|
36
|
+
return null;
|
|
37
|
+
const carriers = Array.from(new Set(segs.map((s) => s.carrierCode)));
|
|
38
|
+
const stops = segs.length - 1;
|
|
39
|
+
const hasCodeshare = segs.some((s) => s.operatingCarrierCode != null && s.operatingCarrierCode !== s.carrierCode);
|
|
40
|
+
const hasInterline = carriers.length > 1;
|
|
41
|
+
return (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "flex shrink-0 items-center -space-x-1.5", children: carriers.map((code) => (_jsx(AirlineLogo, { iataCode: code, name: carrierName?.(code), size: 28 }, code))) }), _jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-3", children: [_jsx(Endpoint, { at: first.departure.at, iata: first.departure.iataCode }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col items-center gap-1", children: [_jsx("div", { className: "text-[11px] text-muted-foreground", children: formatDuration(itinerary.duration) }), _jsxs("div", { className: "flex w-full items-center gap-1.5", children: [_jsx("div", { className: "h-px flex-1 bg-border" }), _jsx(Plane, { className: "h-3 w-3 text-muted-foreground" }), _jsx("div", { className: "h-px flex-1 bg-border" })] }), _jsxs("div", { className: "flex flex-wrap items-center justify-center gap-1.5 text-[11px] text-muted-foreground", children: [stops === 0 ? (_jsx("span", { className: "font-medium text-emerald-600", children: "Nonstop" })) : (_jsxs("span", { children: [stops, " stop", stops > 1 ? "s" : "", " via", " ", segs
|
|
42
|
+
.slice(0, -1)
|
|
43
|
+
.map((s) => s.arrival.iataCode)
|
|
44
|
+
.join(", ")] })), hasInterline && (_jsx(Badge, { variant: "secondary", className: "px-1.5 py-0 text-[9px]", children: "Interline" })), hasCodeshare && (_jsx(Badge, { variant: "secondary", className: "px-1.5 py-0 text-[9px]", children: "Codeshare" }))] })] }), _jsx(Endpoint, { at: last.arrival.at, iata: last.arrival.iataCode, align: "end" })] }), _jsx(Badge, { variant: "outline", className: "shrink-0 capitalize", children: first.cabin.replace("_", " ") })] }));
|
|
45
|
+
}
|
|
46
|
+
function Endpoint({ at, iata, align = "start", }) {
|
|
47
|
+
return (_jsxs("div", { className: cn("flex shrink-0 flex-col leading-tight", align === "end" ? "items-end" : "items-start"), children: [_jsx("span", { className: "font-semibold text-base tabular-nums", children: formatTime(at) }), _jsx("span", { className: "font-mono text-muted-foreground text-xs", children: iata })] }));
|
|
48
|
+
}
|
|
49
|
+
function formatMoney(amount, currency) {
|
|
50
|
+
const n = Number(amount);
|
|
51
|
+
if (!Number.isFinite(n))
|
|
52
|
+
return `${amount} ${currency}`;
|
|
53
|
+
return new Intl.NumberFormat(undefined, {
|
|
54
|
+
style: "currency",
|
|
55
|
+
currency,
|
|
56
|
+
maximumFractionDigits: 0,
|
|
57
|
+
}).format(n);
|
|
58
|
+
}
|
|
59
|
+
function formatTime(iso) {
|
|
60
|
+
const d = new Date(iso);
|
|
61
|
+
if (Number.isNaN(d.getTime()))
|
|
62
|
+
return iso;
|
|
63
|
+
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(d);
|
|
64
|
+
}
|
|
65
|
+
function formatDuration(iso) {
|
|
66
|
+
if (!iso)
|
|
67
|
+
return "";
|
|
68
|
+
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?$/.exec(iso);
|
|
69
|
+
if (!m)
|
|
70
|
+
return iso;
|
|
71
|
+
const h = m[1] ? `${m[1]}h` : "";
|
|
72
|
+
const min = m[2] ? `${m[2]}m` : "";
|
|
73
|
+
return [h, min].filter(Boolean).join(" ") || iso;
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FlightOrder } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export interface FlightOrderConfirmationProps {
|
|
3
|
+
order: FlightOrder;
|
|
4
|
+
/** Optional cancel button — pass when the order is still cancellable. */
|
|
5
|
+
onCancel?: (order: FlightOrder) => void;
|
|
6
|
+
cancelLoading?: boolean;
|
|
7
|
+
/** IATA → human-readable resolvers, forwarded to the embedded offer detail. */
|
|
8
|
+
carrierName?: (iataCode: string) => string | undefined;
|
|
9
|
+
airportName?: (iataCode: string) => string | undefined;
|
|
10
|
+
aircraftName?: (iataCode: string) => string | undefined;
|
|
11
|
+
}
|
|
12
|
+
export declare function FlightOrderConfirmation({ order, onCancel, cancelLoading, carrierName, airportName, aircraftName, }: FlightOrderConfirmationProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
//# sourceMappingURL=flight-order-confirmation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-order-confirmation.d.ts","sourceRoot":"","sources":["../../src/components/flight-order-confirmation.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAUnE,MAAM,WAAW,4BAA4B;IAC3C,KAAK,EAAE,WAAW,CAAA;IAClB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACvC,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,+EAA+E;IAC/E,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,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;CACxD;AAaD,wBAAgB,uBAAuB,CAAC,EACtC,KAAK,EACL,QAAQ,EACR,aAAa,EACb,WAAW,EACX,WAAW,EACX,YAAY,GACb,EAAE,4BAA4B,2CAwH9B"}
|