@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,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@voyantjs/ui/components/tooltip";
|
|
4
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
5
|
+
import { Plane } from "lucide-react";
|
|
6
|
+
/**
|
|
7
|
+
* Visual seat map. Renders each row of the aircraft using the supplied
|
|
8
|
+
* `columnLayout` (with `null` slots becoming aisles), each seat as a
|
|
9
|
+
* clickable tile colour-coded by status + category. Pick markers overlay
|
|
10
|
+
* the seat to indicate which passenger has that seat.
|
|
11
|
+
*
|
|
12
|
+
* Pure presentational — state lives in the parent step.
|
|
13
|
+
*/
|
|
14
|
+
export function FlightSeatMap({ seatMap, picks, onSeatClick, highlightedPaxLabel, className, }) {
|
|
15
|
+
const aircraftName = seatMap.providerData?.aircraftName ?? seatMap.aircraft;
|
|
16
|
+
return (_jsx(TooltipProvider, { delay: 200, children: _jsxs("div", { className: cn("flex flex-col items-center gap-3", className), children: [_jsxs("div", { className: "flex items-center gap-2 text-muted-foreground text-xs", children: [_jsx(Plane, { className: "h-3.5 w-3.5" }), aircraftName ?? "Cabin", highlightedPaxLabel && (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-foreground/60", children: "\u00B7" }), _jsxs("span", { className: "text-foreground", children: ["Picking seat for ", _jsx("span", { className: "font-medium", children: highlightedPaxLabel })] })] }))] }), _jsx("div", { className: "rounded-2xl border bg-card p-4", children: _jsx(Cabin, { seatMap: seatMap, picks: picks, onSeatClick: onSeatClick }) }), _jsx(Legend, {})] }) }));
|
|
17
|
+
}
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
function Cabin({ seatMap, picks, onSeatClick, }) {
|
|
20
|
+
const layout = seatMap.columnLayout;
|
|
21
|
+
const pickIndex = new Map(picks.map((p) => [p.seatNumber, p]));
|
|
22
|
+
return (_jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsx(ColumnHeader, { layout: layout }), seatMap.rows.map((row) => (_jsx(SeatRowView, { rowNumber: row.row, layout: layout, seats: row.seats, pickIndex: pickIndex, onSeatClick: onSeatClick }, row.row)))] }));
|
|
23
|
+
}
|
|
24
|
+
function ColumnHeader({ layout }) {
|
|
25
|
+
return (_jsxs("div", { className: "mb-1 flex items-center gap-1", children: [_jsx("span", { className: "w-6 shrink-0 text-center font-mono text-[10px] text-muted-foreground" }), layout.map((col, i) => col == null ? (_jsx("div", { className: "w-3 shrink-0" }, `gap-${i}`)) : (_jsx("span", { className: "w-7 shrink-0 text-center font-mono text-[10px] text-muted-foreground", children: col }, `col-${i}`)))] }));
|
|
26
|
+
}
|
|
27
|
+
function SeatRowView({ rowNumber, layout, seats, pickIndex, onSeatClick, }) {
|
|
28
|
+
const seatByCol = new Map(seats.map((s) => [s.column, s]));
|
|
29
|
+
return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("span", { className: "w-6 shrink-0 text-center font-mono text-[10px] text-muted-foreground tabular-nums", children: rowNumber }), layout.map((col, i) => {
|
|
30
|
+
if (col == null) {
|
|
31
|
+
return (_jsx("div", { className: "w-3 shrink-0" }, `aisle-${rowNumber}-${i}`));
|
|
32
|
+
}
|
|
33
|
+
const seat = seatByCol.get(col);
|
|
34
|
+
if (!seat) {
|
|
35
|
+
return (_jsx("div", { className: "h-7 w-7 shrink-0 rounded-md border border-dashed border-muted/30" }, `gap-${rowNumber}-${i}`));
|
|
36
|
+
}
|
|
37
|
+
return (_jsx(SeatTile, { seat: seat, pick: pickIndex.get(seat.seatNumber) ?? null, onClick: onSeatClick }, seat.seatNumber));
|
|
38
|
+
})] }));
|
|
39
|
+
}
|
|
40
|
+
function SeatTile({ seat, pick, onClick, }) {
|
|
41
|
+
const isClickable = !!onClick && (seat.status === "available" || seat.status === "selected" || pick != null);
|
|
42
|
+
const tile = (_jsxs("button", { type: "button", disabled: !isClickable, onClick: onClick ? () => onClick(seat) : undefined, className: cn("relative flex h-7 w-7 shrink-0 items-center justify-center rounded-md border font-mono text-[10px] transition-colors", seat.status === "available" && categoryClasses(seat.category), seat.status === "blocked" && "cursor-not-allowed border-transparent bg-muted/60", seat.status === "unavailable" && "cursor-not-allowed border-transparent bg-muted/30", pick && (pick.swatch ?? "bg-primary text-primary-foreground border-primary"), isClickable && !pick && "hover:border-primary hover:bg-primary/5"), children: [pick ? _jsx("span", { className: "font-semibold", children: pick.label }) : null, seat.category === "exit_row" && !pick && (_jsx("span", { className: "absolute top-0 right-0 text-[7px] font-bold leading-none text-amber-600", children: "E" }))] }));
|
|
43
|
+
if (!isClickable && seat.status !== "available")
|
|
44
|
+
return tile;
|
|
45
|
+
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: tile }), _jsx(TooltipContent, { className: "max-w-[220px]", children: _jsx(SeatTooltip, { seat: seat, pickedBy: pick?.label }) })] }));
|
|
46
|
+
}
|
|
47
|
+
function SeatTooltip({ seat, pickedBy }) {
|
|
48
|
+
return (_jsxs("div", { className: "flex flex-col gap-1 text-xs", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "font-mono font-semibold", children: seat.seatNumber }), _jsxs("span", { className: "text-muted-foreground", children: [humanCategory(seat.category), seat.window && " · window", seat.aisle && " · aisle"] })] }), seat.price ? (_jsxs("span", { className: "font-medium", children: ["+", formatMoney(seat.price.amount, seat.price.currency)] })) : (_jsx("span", { className: "text-muted-foreground", children: "No charge" })), seat.notes && _jsx("span", { className: "text-muted-foreground", children: seat.notes }), pickedBy && _jsxs("span", { className: "text-primary", children: ["Picked by ", pickedBy] })] }));
|
|
49
|
+
}
|
|
50
|
+
function Legend() {
|
|
51
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center justify-center gap-3 text-[11px] text-muted-foreground", children: [_jsx(LegendChip, { className: "border-emerald-500/60 bg-card", label: "Available" }), _jsx(LegendChip, { className: "border-cyan-500/60 bg-cyan-500/5", label: "Preferred" }), _jsx(LegendChip, { className: "border-amber-500/60 bg-amber-500/5", label: "Exit row" }), _jsx(LegendChip, { className: "bg-primary text-primary-foreground", label: "Picked" }), _jsx(LegendChip, { className: "bg-muted/60", label: "Taken" })] }));
|
|
52
|
+
}
|
|
53
|
+
function LegendChip({ className, label }) {
|
|
54
|
+
return (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: cn("h-3.5 w-3.5 rounded-sm border", className) }), label] }));
|
|
55
|
+
}
|
|
56
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
57
|
+
function categoryClasses(category) {
|
|
58
|
+
switch (category) {
|
|
59
|
+
case "exit_row":
|
|
60
|
+
return "border-amber-500/60 bg-amber-500/5 text-amber-700";
|
|
61
|
+
case "preferred":
|
|
62
|
+
case "extra_legroom":
|
|
63
|
+
return "border-cyan-500/60 bg-cyan-500/5 text-cyan-700";
|
|
64
|
+
case "premium":
|
|
65
|
+
case "bulkhead":
|
|
66
|
+
return "border-violet-500/60 bg-violet-500/5 text-violet-700";
|
|
67
|
+
default:
|
|
68
|
+
return "border-emerald-500/60 bg-card text-emerald-700";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function humanCategory(c) {
|
|
72
|
+
switch (c) {
|
|
73
|
+
case "exit_row":
|
|
74
|
+
return "Exit row · extra legroom";
|
|
75
|
+
case "extra_legroom":
|
|
76
|
+
return "Extra legroom";
|
|
77
|
+
case "preferred":
|
|
78
|
+
return "Preferred";
|
|
79
|
+
case "premium":
|
|
80
|
+
return "Premium";
|
|
81
|
+
case "bulkhead":
|
|
82
|
+
return "Bulkhead";
|
|
83
|
+
default:
|
|
84
|
+
return "Standard";
|
|
85
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AncillarySelection, FlightOffer, FlightPassenger, PassengerCounts, SeatMap } from "@voyantjs/flights/contract/types";
|
|
2
|
+
type SeatPicks = NonNullable<AncillarySelection["seats"]>;
|
|
3
|
+
type SeatMode = "skip" | "auto" | "now";
|
|
4
|
+
/**
|
|
5
|
+
* Per-segment seat map fetcher contract. The step calls this with the
|
|
6
|
+
* segment id once the user enters "pick now" mode for a given segment;
|
|
7
|
+
* the parent owns the actual TanStack Query call.
|
|
8
|
+
*/
|
|
9
|
+
export interface FlightSeatMapSlot {
|
|
10
|
+
/** Map for this segment, or null while loading / errored. */
|
|
11
|
+
seatMap: SeatMap | null;
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
error?: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface FlightSeatsStepProps {
|
|
16
|
+
outboundOffer: FlightOffer;
|
|
17
|
+
returnOffer?: FlightOffer;
|
|
18
|
+
passengers: FlightPassenger[];
|
|
19
|
+
passengerCounts: PassengerCounts;
|
|
20
|
+
/** Map fetcher invoked with each segment id the user navigates to. */
|
|
21
|
+
getSeatMap: (segment: {
|
|
22
|
+
offerId: string;
|
|
23
|
+
segmentId: string;
|
|
24
|
+
}) => FlightSeatMapSlot;
|
|
25
|
+
value: SeatPicks;
|
|
26
|
+
onChange: (next: SeatPicks) => void;
|
|
27
|
+
/** Tri-option mode the user has chosen — "skip" / "auto" / "now". */
|
|
28
|
+
mode: SeatMode;
|
|
29
|
+
onModeChange: (next: SeatMode) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Wizz-style seat selection step. Tri-option gate up top (Skip / Auto-assign
|
|
33
|
+
* / Pick now) — the first two short-circuit straight to the next step. When
|
|
34
|
+
* the user opens "pick now", they get per-segment tabs and a per-passenger
|
|
35
|
+
* row showing each pax's currently picked seat (or "select"). Clicking a
|
|
36
|
+
* seat assigns it to the active passenger and moves the cursor on.
|
|
37
|
+
*/
|
|
38
|
+
export declare function FlightSeatsStep({ outboundOffer, returnOffer, passengers, passengerCounts, getSeatMap, value, onChange, mode, onModeChange, }: FlightSeatsStepProps): import("react/jsx-runtime").JSX.Element;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=flight-seats-step.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-seats-step.d.ts","sourceRoot":"","sources":["../../src/components/flight-seats-step.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAClB,WAAW,EACX,eAAe,EAEf,eAAe,EAEf,OAAO,EACR,MAAM,kCAAkC,CAAA;AAOzC,KAAK,SAAS,GAAG,WAAW,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC,CAAA;AAEzD,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,CAAA;AAEvC;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,6DAA6D;IAC7D,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,MAAM,WAAW,oBAAoB;IACnC,aAAa,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,UAAU,EAAE,eAAe,EAAE,CAAA;IAC7B,eAAe,EAAE,eAAe,CAAA;IAChC,sEAAsE;IACtE,UAAU,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,KAAK,iBAAiB,CAAA;IAClF,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAA;IACnC,qEAAqE;IACrE,IAAI,EAAE,QAAQ,CAAA;IACd,YAAY,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAA;CACvC;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,EAC9B,aAAa,EACb,WAAW,EACX,UAAU,EACV,eAAe,EACf,UAAU,EACV,KAAK,EACL,QAAQ,EACR,IAAI,EACJ,YAAY,GACb,EAAE,oBAAoB,2CAwGtB"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
4
|
+
import { CheckCircle2, X } from "lucide-react";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { FlightSeatMap } from "./flight-seat-map";
|
|
7
|
+
/**
|
|
8
|
+
* Wizz-style seat selection step. Tri-option gate up top (Skip / Auto-assign
|
|
9
|
+
* / Pick now) — the first two short-circuit straight to the next step. When
|
|
10
|
+
* the user opens "pick now", they get per-segment tabs and a per-passenger
|
|
11
|
+
* row showing each pax's currently picked seat (or "select"). Clicking a
|
|
12
|
+
* seat assigns it to the active passenger and moves the cursor on.
|
|
13
|
+
*/
|
|
14
|
+
export function FlightSeatsStep({ outboundOffer, returnOffer, passengers, passengerCounts, getSeatMap, value, onChange, mode, onModeChange, }) {
|
|
15
|
+
const segments = useMemo(() => collectSegments(outboundOffer, returnOffer), [outboundOffer, returnOffer]);
|
|
16
|
+
const paxRows = useMemo(() => buildPassengerRows(passengers, passengerCounts), [passengers, passengerCounts]);
|
|
17
|
+
const [activeSegmentIdx, setActiveSegmentIdx] = useState(0);
|
|
18
|
+
const [activePaxIdx, setActivePaxIdx] = useState(0);
|
|
19
|
+
// Auto-advance to the next pax once a seat is picked for the current one.
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (mode !== "now")
|
|
22
|
+
return;
|
|
23
|
+
const segId = segments[activeSegmentIdx]?.segmentId;
|
|
24
|
+
if (!segId)
|
|
25
|
+
return;
|
|
26
|
+
const pickedAll = paxRows.length > 0 &&
|
|
27
|
+
paxRows.every((p) => value.some((v) => v.passengerId === p.passengerId && v.segmentId === segId));
|
|
28
|
+
if (pickedAll) {
|
|
29
|
+
// All pax picked for this segment — advance to next segment if any.
|
|
30
|
+
if (activeSegmentIdx < segments.length - 1) {
|
|
31
|
+
setActiveSegmentIdx((i) => i + 1);
|
|
32
|
+
setActivePaxIdx(0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}, [value, segments, activeSegmentIdx, paxRows, mode]);
|
|
36
|
+
const activeSegment = segments[activeSegmentIdx] ?? null;
|
|
37
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { children: [_jsx("h2", { className: "font-semibold text-base", children: "Choose your seats" }), _jsx("p", { className: "text-muted-foreground text-sm", children: "Sit where you want, or let the airline assign seats at check-in." })] }), _jsx(ModePicker, { mode: mode, onChange: onModeChange }), mode === "now" && activeSegment && (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(SegmentTabs, { segments: segments, activeIdx: activeSegmentIdx, paxCount: paxRows.length, picks: value, onChange: (idx) => {
|
|
38
|
+
setActiveSegmentIdx(idx);
|
|
39
|
+
setActivePaxIdx(0);
|
|
40
|
+
} }), _jsx(PaxBar, { paxRows: paxRows, activeIdx: activePaxIdx, picks: value, segmentId: activeSegment.segmentId, onActivate: setActivePaxIdx, onClear: (passengerId) => {
|
|
41
|
+
onChange(value.filter((v) => !(v.passengerId === passengerId && v.segmentId === activeSegment.segmentId)));
|
|
42
|
+
} }), _jsx(SeatMapPanel, { slot: getSeatMap({
|
|
43
|
+
offerId: activeSegment.offerId,
|
|
44
|
+
segmentId: activeSegment.segmentId,
|
|
45
|
+
}), paxRows: paxRows, activePaxIdx: activePaxIdx, picks: value, segmentId: activeSegment.segmentId, onPick: (seat) => {
|
|
46
|
+
const pax = paxRows[activePaxIdx];
|
|
47
|
+
if (!pax)
|
|
48
|
+
return;
|
|
49
|
+
const filtered = value.filter((v) => !(v.passengerId === pax.passengerId && v.segmentId === activeSegment.segmentId));
|
|
50
|
+
onChange([
|
|
51
|
+
...filtered,
|
|
52
|
+
{
|
|
53
|
+
passengerId: pax.passengerId,
|
|
54
|
+
segmentId: activeSegment.segmentId,
|
|
55
|
+
seatNumber: seat.seatNumber,
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
} })] }))] }));
|
|
59
|
+
}
|
|
60
|
+
const PAX_SWATCHES = [
|
|
61
|
+
"bg-primary text-primary-foreground border-primary",
|
|
62
|
+
"bg-violet-600 text-white border-violet-700",
|
|
63
|
+
"bg-amber-600 text-white border-amber-700",
|
|
64
|
+
"bg-rose-600 text-white border-rose-700",
|
|
65
|
+
"bg-teal-600 text-white border-teal-700",
|
|
66
|
+
"bg-indigo-600 text-white border-indigo-700",
|
|
67
|
+
];
|
|
68
|
+
function ModePicker({ mode, onChange }) {
|
|
69
|
+
return (_jsxs("div", { className: "grid gap-2 md:grid-cols-3", children: [_jsx(ModeCard, { active: mode === "skip", onClick: () => onChange("skip"), title: "Pick seats later", body: "Seats will be assigned automatically at check-in. May not be next to each other." }), _jsx(ModeCard, { active: mode === "auto", onClick: () => onChange("auto"), title: "Auto-assign together", body: "Airline picks seats trying to keep your party together. No fee.", recommended: true }), _jsx(ModeCard, { active: mode === "now", onClick: () => onChange("now"), title: "Choose seats now", body: "Pick exact seats per leg \u2014 windows, exit rows, extra legroom." })] }));
|
|
70
|
+
}
|
|
71
|
+
function ModeCard({ active, onClick, title, body, recommended, }) {
|
|
72
|
+
return (_jsxs("button", { type: "button", onClick: onClick, className: cn("relative flex flex-col items-start gap-1 rounded-lg border bg-card p-4 text-left transition-colors", active ? "border-primary ring-2 ring-primary/20" : "hover:border-primary/40"), children: [recommended && (_jsx("span", { className: "absolute top-2 right-2 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-[9px] text-primary uppercase tracking-wider", children: "Recommended" })), active && _jsx(CheckCircle2, { className: "absolute top-3 right-3 h-4 w-4 text-primary" }), _jsx("span", { className: "font-medium text-sm", children: title }), _jsx("span", { className: "text-muted-foreground text-xs", children: body })] }));
|
|
73
|
+
}
|
|
74
|
+
function SegmentTabs({ segments, activeIdx, paxCount, picks, onChange, }) {
|
|
75
|
+
return (_jsx("div", { className: "flex items-center gap-2 overflow-x-auto rounded-md border bg-muted/20 p-1", children: segments.map((seg, idx) => {
|
|
76
|
+
const segPicks = picks.filter((p) => p.segmentId === seg.segmentId).length;
|
|
77
|
+
const complete = segPicks === paxCount && paxCount > 0;
|
|
78
|
+
const active = idx === activeIdx;
|
|
79
|
+
return (_jsxs("button", { type: "button", onClick: () => onChange(idx), className: cn("flex shrink-0 items-center gap-2 rounded px-3 py-1.5 text-sm transition-colors", active && "bg-background shadow-sm", !active && "text-muted-foreground hover:bg-background/50"), children: [complete && _jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-emerald-600" }), _jsxs("span", { className: "font-medium", children: [seg.origin, " \u2192 ", seg.destination] }), _jsxs("span", { className: "font-mono text-[10px] text-muted-foreground", children: [seg.carrier, seg.flightNumber] }), _jsxs("span", { className: "text-[11px] text-muted-foreground tabular-nums", children: [segPicks, "/", paxCount] })] }, seg.segmentId));
|
|
80
|
+
}) }));
|
|
81
|
+
}
|
|
82
|
+
function PaxBar({ paxRows, activeIdx, picks, segmentId, onActivate, onClear, }) {
|
|
83
|
+
return (_jsx("ul", { className: "flex flex-wrap items-center gap-2", children: paxRows.map((pax, idx) => {
|
|
84
|
+
const pick = picks.find((p) => p.passengerId === pax.passengerId && p.segmentId === segmentId);
|
|
85
|
+
const active = idx === activeIdx;
|
|
86
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onActivate(idx), className: cn("flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors", active ? "border-primary bg-primary/5" : "hover:border-primary/40"), children: [_jsx("span", { className: cn("flex h-5 w-5 shrink-0 items-center justify-center rounded-full border font-mono text-[10px] font-semibold", pax.swatch), children: pax.short }), _jsx("span", { className: "font-medium", children: pax.label }), pick ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-mono text-[11px] text-foreground", children: pick.seatNumber }), _jsx(X, { className: "h-3 w-3 cursor-pointer text-muted-foreground hover:text-destructive", onClick: (e) => {
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
onClear(pax.passengerId);
|
|
89
|
+
} })] })) : (_jsx("span", { className: "text-muted-foreground", children: "\u2014" }))] }) }, pax.passengerId));
|
|
90
|
+
}) }));
|
|
91
|
+
}
|
|
92
|
+
function SeatMapPanel({ slot, paxRows, activePaxIdx, picks, segmentId, onPick, }) {
|
|
93
|
+
if (slot.loading) {
|
|
94
|
+
return _jsx("div", { className: "h-72 animate-pulse rounded-2xl bg-muted/40" });
|
|
95
|
+
}
|
|
96
|
+
if (slot.error) {
|
|
97
|
+
return (_jsx("div", { className: "rounded-xl border border-destructive/40 bg-destructive/5 p-4 text-destructive text-sm", children: slot.error }));
|
|
98
|
+
}
|
|
99
|
+
if (!slot.seatMap) {
|
|
100
|
+
return (_jsx("div", { className: "rounded-xl border border-dashed p-6 text-center text-muted-foreground text-sm", children: "Seat map unavailable for this segment." }));
|
|
101
|
+
}
|
|
102
|
+
const markers = picks
|
|
103
|
+
.filter((p) => p.segmentId === segmentId)
|
|
104
|
+
.map((p) => {
|
|
105
|
+
const paxIdx = paxRows.findIndex((r) => r.passengerId === p.passengerId);
|
|
106
|
+
const pax = paxRows[paxIdx];
|
|
107
|
+
if (!pax)
|
|
108
|
+
return null;
|
|
109
|
+
return { seatNumber: p.seatNumber, label: pax.short, swatch: pax.swatch };
|
|
110
|
+
})
|
|
111
|
+
.filter((p) => p !== null);
|
|
112
|
+
return (_jsx(FlightSeatMap, { seatMap: slot.seatMap, picks: markers, onSeatClick: (seat) => {
|
|
113
|
+
if (seat.status !== "available" && seat.status !== "selected")
|
|
114
|
+
return;
|
|
115
|
+
onPick(seat);
|
|
116
|
+
}, highlightedPaxLabel: paxRows[activePaxIdx]?.label }));
|
|
117
|
+
}
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// Helpers
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
function collectSegments(outbound, returnLeg) {
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const seg of itinerarySegments(outbound)) {
|
|
124
|
+
out.push({
|
|
125
|
+
offerId: outbound.offerId,
|
|
126
|
+
segmentId: seg.segmentId,
|
|
127
|
+
legLabel: "Outbound",
|
|
128
|
+
origin: seg.departure.iataCode,
|
|
129
|
+
destination: seg.arrival.iataCode,
|
|
130
|
+
carrier: seg.carrierCode,
|
|
131
|
+
flightNumber: seg.flightNumber,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (returnLeg) {
|
|
135
|
+
for (const seg of itinerarySegments(returnLeg)) {
|
|
136
|
+
out.push({
|
|
137
|
+
offerId: returnLeg.offerId,
|
|
138
|
+
segmentId: seg.segmentId,
|
|
139
|
+
legLabel: "Return",
|
|
140
|
+
origin: seg.departure.iataCode,
|
|
141
|
+
destination: seg.arrival.iataCode,
|
|
142
|
+
carrier: seg.carrierCode,
|
|
143
|
+
flightNumber: seg.flightNumber,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function itinerarySegments(offer) {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const itin of offer.itineraries) {
|
|
152
|
+
for (const seg of itin.segments)
|
|
153
|
+
out.push(seg);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
function buildPassengerRows(passengers, counts) {
|
|
158
|
+
const rows = [];
|
|
159
|
+
const total = passengers.length > 0
|
|
160
|
+
? passengers.length
|
|
161
|
+
: counts.adults + (counts.children ?? 0) + (counts.infants ?? 0);
|
|
162
|
+
for (let i = 0; i < total; i++) {
|
|
163
|
+
const p = passengers[i];
|
|
164
|
+
if (p) {
|
|
165
|
+
rows.push({
|
|
166
|
+
passengerId: p.passengerId,
|
|
167
|
+
label: nameOrFallback(p, i),
|
|
168
|
+
short: shortLabel(p, i),
|
|
169
|
+
swatch: PAX_SWATCHES[i % PAX_SWATCHES.length] ?? PAX_SWATCHES[0],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
rows.push({
|
|
174
|
+
passengerId: synthPaxId(i, counts),
|
|
175
|
+
label: synthPaxLabel(i, counts),
|
|
176
|
+
short: String(i + 1),
|
|
177
|
+
swatch: PAX_SWATCHES[i % PAX_SWATCHES.length] ?? PAX_SWATCHES[0],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return rows;
|
|
182
|
+
}
|
|
183
|
+
function nameOrFallback(p, idx) {
|
|
184
|
+
const full = `${p.firstName} ${p.lastName}`.trim();
|
|
185
|
+
if (full)
|
|
186
|
+
return full;
|
|
187
|
+
const cap = p.type[0]?.toUpperCase() + p.type.slice(1);
|
|
188
|
+
return `${cap} ${idx + 1}`;
|
|
189
|
+
}
|
|
190
|
+
function shortLabel(p, idx) {
|
|
191
|
+
const initials = `${p.firstName[0] ?? ""}${p.lastName[0] ?? ""}`.trim();
|
|
192
|
+
if (initials)
|
|
193
|
+
return initials.toUpperCase();
|
|
194
|
+
return String(idx + 1);
|
|
195
|
+
}
|
|
196
|
+
function synthPaxId(i, counts) {
|
|
197
|
+
if (i < counts.adults)
|
|
198
|
+
return `pax_adult_${i + 1}`;
|
|
199
|
+
const afterAdults = i - counts.adults;
|
|
200
|
+
if (afterAdults < (counts.children ?? 0))
|
|
201
|
+
return `pax_child_${afterAdults + 1}`;
|
|
202
|
+
return `pax_infant_${i - counts.adults - (counts.children ?? 0) + 1}`;
|
|
203
|
+
}
|
|
204
|
+
function synthPaxLabel(i, counts) {
|
|
205
|
+
if (i < counts.adults)
|
|
206
|
+
return `Adult ${i + 1}`;
|
|
207
|
+
const afterAdults = i - counts.adults;
|
|
208
|
+
if (afterAdults < (counts.children ?? 0))
|
|
209
|
+
return `Child ${afterAdults + 1}`;
|
|
210
|
+
return `Infant ${i - counts.adults - (counts.children ?? 0) + 1}`;
|
|
211
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AncillaryCatalog, AncillarySelection, FlightOffer, FlightPassenger, PassengerCounts } from "@voyantjs/flights/contract/types";
|
|
2
|
+
type AssistancePicks = NonNullable<AncillarySelection["assistance"]>;
|
|
3
|
+
type ExtrasPicks = NonNullable<AncillarySelection["extras"]>;
|
|
4
|
+
export interface FlightServicesStepProps {
|
|
5
|
+
/** Per-leg catalogs — outbound required, return only when round-trip. */
|
|
6
|
+
outboundCatalog: AncillaryCatalog | null;
|
|
7
|
+
returnCatalog?: AncillaryCatalog | null;
|
|
8
|
+
outboundOffer: FlightOffer;
|
|
9
|
+
returnOffer?: FlightOffer;
|
|
10
|
+
passengers: FlightPassenger[];
|
|
11
|
+
passengerCounts: PassengerCounts;
|
|
12
|
+
assistance: AssistancePicks;
|
|
13
|
+
extras: ExtrasPicks;
|
|
14
|
+
onAssistanceChange: (next: AssistancePicks) => void;
|
|
15
|
+
onExtrasChange: (next: ExtrasPicks) => void;
|
|
16
|
+
loading?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Combined services step covering special assistance (per-pax, trip-wide)
|
|
20
|
+
* and extras (per-pax, per-leg). Assistance is a flat checkbox list per
|
|
21
|
+
* passenger; extras are stepper-style line items so the operator can add
|
|
22
|
+
* multiples (e.g. two pets in cabin). Both are optional — passengers can
|
|
23
|
+
* leave the step blank and continue.
|
|
24
|
+
*/
|
|
25
|
+
export declare function FlightServicesStep({ outboundCatalog, returnCatalog, outboundOffer, returnOffer, passengers, passengerCounts, assistance, extras, onAssistanceChange, onExtrasChange, loading, }: FlightServicesStepProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=flight-services-step.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-services-step.d.ts","sourceRoot":"","sources":["../../src/components/flight-services-step.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,gBAAgB,EAEhB,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,eAAe,EAChB,MAAM,kCAAkC,CAAA;AAOzC,KAAK,eAAe,GAAG,WAAW,CAAC,kBAAkB,CAAC,YAAY,CAAC,CAAC,CAAA;AACpE,KAAK,WAAW,GAAG,WAAW,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAC,CAAA;AAE5D,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,eAAe,EAAE,gBAAgB,GAAG,IAAI,CAAA;IACxC,aAAa,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAA;IACvC,aAAa,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,UAAU,EAAE,eAAe,EAAE,CAAA;IAC7B,eAAe,EAAE,eAAe,CAAA;IAChC,UAAU,EAAE,eAAe,CAAA;IAC3B,MAAM,EAAE,WAAW,CAAA;IACnB,kBAAkB,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;IACnD,cAAc,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,CAAA;IAC3C,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,eAAe,EACf,aAAa,EACb,aAAa,EACb,WAAW,EACX,UAAU,EACV,eAAe,EACf,UAAU,EACV,MAAM,EACN,kBAAkB,EAClB,cAAc,EACd,OAAO,GACR,EAAE,uBAAuB,2CAyFzB"}
|
|
@@ -0,0 +1,110 @@
|
|
|
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 { cn } from "@voyantjs/ui/lib/utils";
|
|
6
|
+
import { Accessibility, Minus, Package, Plus, Sparkles } from "lucide-react";
|
|
7
|
+
import { useMemo } from "react";
|
|
8
|
+
/**
|
|
9
|
+
* Combined services step covering special assistance (per-pax, trip-wide)
|
|
10
|
+
* and extras (per-pax, per-leg). Assistance is a flat checkbox list per
|
|
11
|
+
* passenger; extras are stepper-style line items so the operator can add
|
|
12
|
+
* multiples (e.g. two pets in cabin). Both are optional — passengers can
|
|
13
|
+
* leave the step blank and continue.
|
|
14
|
+
*/
|
|
15
|
+
export function FlightServicesStep({ outboundCatalog, returnCatalog, outboundOffer, returnOffer, passengers, passengerCounts, assistance, extras, onAssistanceChange, onExtrasChange, loading, }) {
|
|
16
|
+
const isRoundTrip = !!returnOffer;
|
|
17
|
+
const paxRows = useMemo(() => buildPassengerRows(passengers, passengerCounts), [passengers, passengerCounts]);
|
|
18
|
+
if (loading) {
|
|
19
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("div", { className: "h-8 w-48 animate-pulse rounded bg-muted/40" }), _jsx("div", { className: "h-40 animate-pulse rounded-xl bg-muted/40" })] }));
|
|
20
|
+
}
|
|
21
|
+
if (!outboundCatalog) {
|
|
22
|
+
return (_jsx("div", { className: "rounded-xl border border-dashed p-6 text-center text-muted-foreground text-sm", children: "Services couldn't be loaded for this offer." }));
|
|
23
|
+
}
|
|
24
|
+
const toggleAssistance = (passengerId, optionId, checked) => {
|
|
25
|
+
const filtered = assistance.filter((p) => !(p.passengerId === passengerId && p.optionId === optionId));
|
|
26
|
+
if (checked) {
|
|
27
|
+
onAssistanceChange([...filtered, { passengerId, optionId }]);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
onAssistanceChange(filtered);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const setExtraQty = (passengerId, sliceIndex, optionId, quantity) => {
|
|
34
|
+
const filtered = extras.filter((p) => !(p.passengerId === passengerId && p.sliceIndex === sliceIndex && p.optionId === optionId));
|
|
35
|
+
if (quantity > 0) {
|
|
36
|
+
onExtrasChange([...filtered, { passengerId, sliceIndex, optionId, quantity }]);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
onExtrasChange(filtered);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { children: [_jsx("h2", { className: "font-semibold text-base", children: "Services & extras" }), _jsx("p", { className: "text-muted-foreground text-sm", children: "Optional \u2014 leave anything blank to skip." })] }), _jsx(AssistanceSection, { passengers: paxRows, options: outboundCatalog.assistance, value: assistance, onToggle: toggleAssistance }), _jsx(ExtrasSection, { legLabel: "Outbound", offer: outboundOffer, catalog: outboundCatalog, passengers: paxRows, sliceIndex: 0, value: extras, onSetQty: setExtraQty }), isRoundTrip && returnCatalog && returnOffer && (_jsx(ExtrasSection, { legLabel: "Return", offer: returnOffer, catalog: returnCatalog, passengers: paxRows, sliceIndex: 1, value: extras, onSetQty: setExtraQty }))] }));
|
|
43
|
+
}
|
|
44
|
+
function AssistanceSection({ passengers, options, value, onToggle, }) {
|
|
45
|
+
return (_jsxs("section", { className: "rounded-xl border bg-card p-5 shadow-sm", children: [_jsxs("header", { className: "mb-4 flex items-center gap-2", children: [_jsx(Accessibility, { className: "h-4 w-4 text-muted-foreground" }), _jsx("h3", { className: "font-medium text-sm", children: "Special assistance" })] }), _jsx("div", { className: "flex flex-col gap-5", children: passengers.map((pax) => {
|
|
46
|
+
const paxPicks = value.filter((v) => v.passengerId === pax.passengerId);
|
|
47
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2", children: [_jsx("span", { className: "font-medium text-sm", children: pax.label }), _jsx("span", { className: "text-[11px] text-muted-foreground", children: paxPicks.length === 0 ? "No assistance needed" : `${paxPicks.length} selected` })] }), _jsx("div", { className: "flex flex-wrap gap-2", children: options.map((opt) => {
|
|
48
|
+
const checked = paxPicks.some((p) => p.optionId === opt.id);
|
|
49
|
+
const id = `svc-${pax.passengerId}-${opt.id}`;
|
|
50
|
+
return (_jsxs("div", { className: cn("flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors", checked
|
|
51
|
+
? "border-primary bg-primary/5"
|
|
52
|
+
: "hover:border-primary/40 hover:bg-accent/30"), children: [_jsx(Checkbox, { id: id, checked: checked, onCheckedChange: (v) => onToggle(pax.passengerId, opt.id, !!v) }), _jsx("label", { htmlFor: id, className: "cursor-pointer", children: opt.label })] }, opt.id));
|
|
53
|
+
}) })] }, pax.passengerId));
|
|
54
|
+
}) })] }));
|
|
55
|
+
}
|
|
56
|
+
function ExtrasSection({ legLabel, offer, catalog, passengers, sliceIndex, value, onSetQty, }) {
|
|
57
|
+
if (catalog.extras.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
const itin = offer.itineraries[0];
|
|
60
|
+
const first = itin?.segments[0];
|
|
61
|
+
const last = itin?.segments[itin.segments.length - 1];
|
|
62
|
+
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: "flex items-center gap-2 font-medium text-sm", children: [_jsx(Sparkles, { className: "h-4 w-4 text-muted-foreground" }), legLabel, " extras"] }), first && last && (_jsxs("span", { className: "text-muted-foreground text-xs", children: [first.departure.iataCode, " \u2192 ", last.arrival.iataCode] }))] }), _jsx("div", { className: "flex flex-col gap-4", children: passengers.map((pax) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "font-medium text-sm", children: pax.label }), _jsx("ul", { className: "flex flex-col gap-1.5", children: catalog.extras.map((opt) => {
|
|
63
|
+
const pick = value.find((v) => v.passengerId === pax.passengerId &&
|
|
64
|
+
v.sliceIndex === sliceIndex &&
|
|
65
|
+
v.optionId === opt.id);
|
|
66
|
+
const qty = pick?.quantity ?? 0;
|
|
67
|
+
return (_jsx(ExtraRow, { option: opt, quantity: qty, onChange: (next) => onSetQty(pax.passengerId, sliceIndex, opt.id, next) }, opt.id));
|
|
68
|
+
}) })] }, pax.passengerId))) })] }));
|
|
69
|
+
}
|
|
70
|
+
function ExtraRow({ option, quantity, onChange, }) {
|
|
71
|
+
return (_jsxs("li", { className: "flex items-center justify-between gap-3 rounded-md border bg-card px-3 py-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Package, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate text-sm", children: option.label }), _jsx("span", { className: "font-mono text-[11px] text-muted-foreground", children: formatMoney(option.price.amount, option.price.currency) })] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { type: "button", variant: "outline", size: "icon", className: "h-7 w-7", onClick: () => onChange(Math.max(0, quantity - 1)), disabled: quantity === 0, children: _jsx(Minus, { className: "h-3 w-3" }) }), _jsx("span", { className: "min-w-6 text-center font-medium text-sm tabular-nums", children: quantity }), _jsx(Button, { type: "button", variant: "outline", size: "icon", className: "h-7 w-7", onClick: () => onChange(Math.min(9, quantity + 1)), children: _jsx(Plus, { className: "h-3 w-3" }) })] })] }));
|
|
72
|
+
}
|
|
73
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
74
|
+
function buildPassengerRows(passengers, counts) {
|
|
75
|
+
if (passengers.length > 0) {
|
|
76
|
+
return passengers.map((p) => ({
|
|
77
|
+
passengerId: p.passengerId,
|
|
78
|
+
label: nameOrFallback(p),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
const out = [];
|
|
82
|
+
for (let i = 1; i <= counts.adults; i++) {
|
|
83
|
+
out.push({ passengerId: `pax_adult_${i}`, label: `Adult ${i}` });
|
|
84
|
+
}
|
|
85
|
+
for (let i = 1; i <= (counts.children ?? 0); i++) {
|
|
86
|
+
out.push({ passengerId: `pax_child_${i}`, label: `Child ${i}` });
|
|
87
|
+
}
|
|
88
|
+
for (let i = 1; i <= (counts.infants ?? 0); i++) {
|
|
89
|
+
out.push({ passengerId: `pax_infant_${i}`, label: `Infant ${i}` });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function nameOrFallback(p) {
|
|
94
|
+
const full = `${p.firstName} ${p.lastName}`.trim();
|
|
95
|
+
if (full)
|
|
96
|
+
return full;
|
|
97
|
+
const idx = p.passengerId.match(/_(\d+)$/)?.[1] ?? "1";
|
|
98
|
+
const cap = p.type[0]?.toUpperCase() + p.type.slice(1);
|
|
99
|
+
return `${cap} ${idx}`;
|
|
100
|
+
}
|
|
101
|
+
function formatMoney(amount, currency) {
|
|
102
|
+
const n = Number(amount);
|
|
103
|
+
if (!Number.isFinite(n))
|
|
104
|
+
return `${amount} ${currency}`;
|
|
105
|
+
return new Intl.NumberFormat(undefined, {
|
|
106
|
+
style: "currency",
|
|
107
|
+
currency,
|
|
108
|
+
maximumFractionDigits: 0,
|
|
109
|
+
}).format(n);
|
|
110
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CabinClass, PassengerCounts } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export interface PaxCabinPopoverProps {
|
|
3
|
+
passengers: PassengerCounts;
|
|
4
|
+
cabin: CabinClass;
|
|
5
|
+
onChange: (next: {
|
|
6
|
+
passengers: PassengerCounts;
|
|
7
|
+
cabin: CabinClass;
|
|
8
|
+
}) => void;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Compact pax + cabin selector — single trigger button summarizing
|
|
13
|
+
* "2 adults · Economy" that opens a popover with steppers for each pax
|
|
14
|
+
* type and a cabin select. Mirrors the Google Flights / Skyscanner
|
|
15
|
+
* pattern; keeps the search form on one row.
|
|
16
|
+
*/
|
|
17
|
+
export declare function PaxCabinPopover({ passengers, cabin, onChange, className }: PaxCabinPopoverProps): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
//# sourceMappingURL=pax-cabin-popover.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pax-cabin-popover.d.ts","sourceRoot":"","sources":["../../src/components/pax-cabin-popover.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAA;AAYnF,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,eAAe,CAAA;IAC3B,KAAK,EAAE,UAAU,CAAA;IACjB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,UAAU,EAAE,eAAe,CAAC;QAAC,KAAK,EAAE,UAAU,CAAA;KAAE,KAAK,IAAI,CAAA;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AASD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,oBAAoB,2CAuE/F"}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { Popover, PopoverContent, PopoverTrigger } from "@voyantjs/ui/components/popover";
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@voyantjs/ui/components/select";
|
|
6
|
+
import { Minus, Plus, Users } from "lucide-react";
|
|
7
|
+
const CABIN_LABEL = {
|
|
8
|
+
economy: "Economy",
|
|
9
|
+
premium_economy: "Premium economy",
|
|
10
|
+
business: "Business",
|
|
11
|
+
first: "First",
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Compact pax + cabin selector — single trigger button summarizing
|
|
15
|
+
* "2 adults · Economy" that opens a popover with steppers for each pax
|
|
16
|
+
* type and a cabin select. Mirrors the Google Flights / Skyscanner
|
|
17
|
+
* pattern; keeps the search form on one row.
|
|
18
|
+
*/
|
|
19
|
+
export function PaxCabinPopover({ passengers, cabin, onChange, className }) {
|
|
20
|
+
const total = passengers.adults + (passengers.children ?? 0) + (passengers.infants ?? 0);
|
|
21
|
+
const summary = `${total} ${total === 1 ? "passenger" : "passengers"} · ${CABIN_LABEL[cabin]}`;
|
|
22
|
+
const setCount = (key, value) => {
|
|
23
|
+
onChange({
|
|
24
|
+
passengers: {
|
|
25
|
+
...passengers,
|
|
26
|
+
[key]: Math.max(key === "adults" ? 1 : 0, value),
|
|
27
|
+
},
|
|
28
|
+
cabin,
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
return (_jsxs(Popover, { children: [_jsxs(PopoverTrigger, { render: _jsx(Button, { type: "button", variant: "outline", size: "lg", className: className }), children: [_jsx(Users, { className: "h-4 w-4 text-muted-foreground" }), _jsx("span", { className: "text-sm", children: summary })] }), _jsx(PopoverContent, { align: "end", children: _jsxs("div", { className: "flex flex-col gap-3", children: [_jsx(PaxStepper, { label: "Adults", sublabel: "12+", value: passengers.adults, min: 1, onChange: (v) => setCount("adults", v) }), _jsx(PaxStepper, { label: "Children", sublabel: "2-11", value: passengers.children ?? 0, min: 0, onChange: (v) => setCount("children", v) }), _jsx(PaxStepper, { label: "Infants", sublabel: "under 2", value: passengers.infants ?? 0, min: 0, onChange: (v) => setCount("infants", v) }), _jsxs("div", { className: "mt-1 flex flex-col gap-1.5 border-t pt-3", children: [_jsx("span", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: "Cabin" }), _jsxs(Select, { value: cabin, onValueChange: (v) => {
|
|
32
|
+
if (v)
|
|
33
|
+
onChange({ passengers, cabin: v });
|
|
34
|
+
}, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: Object.keys(CABIN_LABEL).map((c) => (_jsx(SelectItem, { value: c, children: CABIN_LABEL[c] }, c))) })] })] })] }) })] }));
|
|
35
|
+
}
|
|
36
|
+
function PaxStepper({ label, sublabel, value, min, onChange, }) {
|
|
37
|
+
return (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex flex-col leading-tight", children: [_jsx("span", { className: "text-sm font-medium", children: label }), _jsx("span", { className: "text-[11px] text-muted-foreground", children: sublabel })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "outline", size: "icon", className: "h-7 w-7", onClick: () => onChange(value - 1), disabled: value <= min, "aria-label": `Decrease ${label}`, children: _jsx(Minus, { className: "h-3.5 w-3.5" }) }), _jsx("span", { className: "w-6 text-center text-sm font-medium tabular-nums", children: value }), _jsx(Button, { type: "button", variant: "outline", size: "icon", className: "h-7 w-7", onClick: () => onChange(value + 1), "aria-label": `Increase ${label}`, children: _jsx(Plus, { className: "h-3.5 w-3.5" }) })] })] }));
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CabinClass, FlightSearchRequest } from "@voyantjs/flights/contract/types";
|
|
2
|
+
export interface PopularRoute {
|
|
3
|
+
/** Origin IATA code. */
|
|
4
|
+
origin: string;
|
|
5
|
+
/** Display label for the origin (e.g. city). */
|
|
6
|
+
originLabel: string;
|
|
7
|
+
/** Destination IATA code. */
|
|
8
|
+
destination: string;
|
|
9
|
+
destinationLabel: string;
|
|
10
|
+
/** Optional human-readable hint shown beneath the route ("From €120"). */
|
|
11
|
+
hint?: string;
|
|
12
|
+
/** Trip flavor tag — purely cosmetic. */
|
|
13
|
+
tag?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PopularRoutesProps {
|
|
16
|
+
routes: PopularRoute[];
|
|
17
|
+
/** Called when the user clicks a card. The page builds a search from this. */
|
|
18
|
+
onSelect: (request: FlightSearchRequest) => void;
|
|
19
|
+
/** Trip type used for the synthesized request. Default `"round_trip"`. */
|
|
20
|
+
tripType?: "one_way" | "round_trip";
|
|
21
|
+
/** Days from today to use as departure date. Default 14. */
|
|
22
|
+
daysOut?: number;
|
|
23
|
+
/** Trip duration when round-trip. Default 7. */
|
|
24
|
+
tripNights?: number;
|
|
25
|
+
/** Cabin class for the synthesized request. Default `"economy"`. */
|
|
26
|
+
cabin?: CabinClass;
|
|
27
|
+
/** Adults for the synthesized request. Default 1. */
|
|
28
|
+
adults?: number;
|
|
29
|
+
className?: string;
|
|
30
|
+
/** Section title; pass `null` to hide. */
|
|
31
|
+
title?: string | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Default route set — recognizable city pairs across regions so a fresh
|
|
35
|
+
* /flights page has something to click without having to type airports.
|
|
36
|
+
* Pages can pass their own `routes` to override.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_POPULAR_ROUTES: PopularRoute[];
|
|
39
|
+
/**
|
|
40
|
+
* A grid of clickable popular-route cards. Each card synthesizes a
|
|
41
|
+
* `FlightSearchRequest` (round-trip by default, 14 days out, 7 nights) and
|
|
42
|
+
* fires `onSelect` so the page can prefill its form + run the search in one
|
|
43
|
+
* step. Designed for the empty-state of a flights page: gives the user
|
|
44
|
+
* something to do without typing airport codes.
|
|
45
|
+
*/
|
|
46
|
+
export declare function PopularRoutes({ routes, onSelect, tripType, daysOut, tripNights, cabin, adults, className, title, }: PopularRoutesProps): import("react/jsx-runtime").JSX.Element;
|
|
47
|
+
//# sourceMappingURL=popular-routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"popular-routes.d.ts","sourceRoot":"","sources":["../../src/components/popular-routes.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAA;AAKvF,MAAM,WAAW,YAAY;IAC3B,wBAAwB;IACxB,MAAM,EAAE,MAAM,CAAA;IACd,gDAAgD;IAChD,WAAW,EAAE,MAAM,CAAA;IACnB,6BAA6B;IAC7B,WAAW,EAAE,MAAM,CAAA;IACnB,gBAAgB,EAAE,MAAM,CAAA;IACxB,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,yCAAyC;IACzC,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,YAAY,EAAE,CAAA;IACtB,8EAA8E;IAC9E,QAAQ,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAA;IAChD,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,SAAS,GAAG,YAAY,CAAA;IACnC,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oEAAoE;IACpE,KAAK,CAAC,EAAE,UAAU,CAAA;IAClB,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,YAAY,EAiFhD,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,EAC5B,MAAM,EACN,QAAQ,EACR,QAAuB,EACvB,OAAY,EACZ,UAAc,EACd,KAAiB,EACjB,MAAU,EACV,SAAS,EACT,KAAwB,GACzB,EAAE,kBAAkB,2CA2DpB"}
|