@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,486 @@
|
|
|
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 { cn } from "@voyantjs/ui/lib/utils";
|
|
5
|
+
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
|
|
6
|
+
import { useMemo, useState } from "react";
|
|
7
|
+
import { FlightBaggageStep } from "./flight-baggage-step";
|
|
8
|
+
import { emptyBillingValue, FlightBillingStep, validateBilling, } from "./flight-billing-step";
|
|
9
|
+
import { FlightBookingLedger, } from "./flight-booking-ledger";
|
|
10
|
+
import { FlightFareUpsellStep } from "./flight-fare-upsell-step";
|
|
11
|
+
import { FlightItinerary } from "./flight-itinerary";
|
|
12
|
+
import { FlightPassengerForm, validatePassengers, } from "./flight-passenger-form";
|
|
13
|
+
import { FlightPaymentStep, } from "./flight-payment-step";
|
|
14
|
+
import { FlightSeatsStep } from "./flight-seats-step";
|
|
15
|
+
import { FlightServicesStep } from "./flight-services-step";
|
|
16
|
+
const ALL_STEPS = [
|
|
17
|
+
{ id: "review", label: "Review" },
|
|
18
|
+
{ id: "fares", label: "Fare" },
|
|
19
|
+
{ id: "passengers", label: "Passengers" },
|
|
20
|
+
{ id: "bags", label: "Bags" },
|
|
21
|
+
{ id: "seats", label: "Seats" },
|
|
22
|
+
{ id: "services", label: "Services" },
|
|
23
|
+
{ id: "billing", label: "Billing" },
|
|
24
|
+
{ id: "payment", label: "Payment" },
|
|
25
|
+
{ id: "confirm", label: "Confirm" },
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Steps that always render — the rest are adapter-capability-gated.
|
|
29
|
+
* - `fares` shows when the offer surfaces `fareBundles`
|
|
30
|
+
* - `bags` shows when the ancillary catalog has any baggage options
|
|
31
|
+
* - `seats` shows when a seat-map fetcher is wired
|
|
32
|
+
* - `services` shows when the ancillary catalog has assistance OR extras
|
|
33
|
+
*
|
|
34
|
+
* Travel documents live as an opt-in subsection on each passenger card —
|
|
35
|
+
* collapsed by default ("Add at check-in instead"), expanded when the
|
|
36
|
+
* operator wants to capture passport / national-id up front.
|
|
37
|
+
*/
|
|
38
|
+
const ALWAYS_VISIBLE = new Set([
|
|
39
|
+
"review",
|
|
40
|
+
"passengers",
|
|
41
|
+
"billing",
|
|
42
|
+
"payment",
|
|
43
|
+
"confirm",
|
|
44
|
+
]);
|
|
45
|
+
/**
|
|
46
|
+
* Multi-step booking shell with a sticky right-rail price ledger and a top
|
|
47
|
+
* stepper. Steps: review → passengers → bags → services → contact+payment
|
|
48
|
+
* → confirm. The shell owns the per-leg `FlightItinerarySelection` plus
|
|
49
|
+
* ancillary picks (baggage / assistance / extras), and synthesizes a
|
|
50
|
+
* combined `FlightOffer` (with both itineraries merged) when `onBook` is
|
|
51
|
+
* called — so the booking adapter sees a single offer with all legs intact.
|
|
52
|
+
*/
|
|
53
|
+
export function FlightBookingShell({ selection, passengers, onBook, onBooked, onCancel, onEditOutbound, onEditReturn, carrierName, airportName, ancillaries, seatMaps, savedPaymentMethods, paymentCapabilities, documentsRequired, renderPassengerPicker, renderBillingPersonPicker, renderBillingOrgPicker, onSaveBillingDefaults, }) {
|
|
54
|
+
const [stepId, setStepId] = useState("review");
|
|
55
|
+
const [paxList, setPaxList] = useState([]);
|
|
56
|
+
const [billing, setBilling] = useState(emptyBillingValue);
|
|
57
|
+
const [payment, setPayment] = useState({ type: "hold" });
|
|
58
|
+
const [paymentSavedId, setPaymentSavedId] = useState(null);
|
|
59
|
+
const [submitting, setSubmitting] = useState(false);
|
|
60
|
+
const [error, setError] = useState(null);
|
|
61
|
+
const [baggage, setBaggage] = useState([]);
|
|
62
|
+
const [assistance, setAssistance] = useState([]);
|
|
63
|
+
const [extras, setExtras] = useState([]);
|
|
64
|
+
const [seats, setSeats] = useState([]);
|
|
65
|
+
const [fareBundles, setFareBundles] = useState([]);
|
|
66
|
+
const [seatsMode, setSeatsMode] = useState("auto");
|
|
67
|
+
const [sameForBothDirections, setSameForBothDirections] = useState(true);
|
|
68
|
+
const [sameFareForAllPax, setSameFareForAllPax] = useState(true);
|
|
69
|
+
// Compute the visible step list dynamically — adapter capability gating.
|
|
70
|
+
// Loading-aware: show optionals while the catalog is still in flight so the
|
|
71
|
+
// stepper doesn't grow when data lands; once loaded, hide what's empty.
|
|
72
|
+
const steps = useMemo(() => {
|
|
73
|
+
const hasFareBundles = (selection.outbound.fareBundles?.length ?? 0) > 0 ||
|
|
74
|
+
(selection.return?.fareBundles?.length ?? 0) > 0;
|
|
75
|
+
const cat = ancillaries?.outboundCatalog;
|
|
76
|
+
const catReturn = ancillaries?.returnCatalog;
|
|
77
|
+
const ancillariesLoading = !!ancillaries?.loading;
|
|
78
|
+
const hasBags = ancillariesLoading || (cat?.baggage.length ?? 0) > 0 || (catReturn?.baggage.length ?? 0) > 0;
|
|
79
|
+
const hasServices = ancillariesLoading ||
|
|
80
|
+
(cat?.assistance.length ?? 0) > 0 ||
|
|
81
|
+
(cat?.extras.length ?? 0) > 0 ||
|
|
82
|
+
(catReturn?.assistance.length ?? 0) > 0 ||
|
|
83
|
+
(catReturn?.extras.length ?? 0) > 0;
|
|
84
|
+
const hasSeats = !!seatMaps;
|
|
85
|
+
return ALL_STEPS.filter((s) => {
|
|
86
|
+
if (ALWAYS_VISIBLE.has(s.id))
|
|
87
|
+
return true;
|
|
88
|
+
if (s.id === "fares")
|
|
89
|
+
return hasFareBundles;
|
|
90
|
+
if (s.id === "bags")
|
|
91
|
+
return hasBags;
|
|
92
|
+
if (s.id === "seats")
|
|
93
|
+
return hasSeats;
|
|
94
|
+
if (s.id === "services")
|
|
95
|
+
return hasServices;
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
}, [
|
|
99
|
+
selection.outbound.fareBundles,
|
|
100
|
+
selection.return?.fareBundles,
|
|
101
|
+
ancillaries?.outboundCatalog,
|
|
102
|
+
ancillaries?.returnCatalog,
|
|
103
|
+
ancillaries?.loading,
|
|
104
|
+
seatMaps,
|
|
105
|
+
]);
|
|
106
|
+
// If the visible-step list shrinks while we're sitting on a now-hidden
|
|
107
|
+
// step (e.g., catalog loaded with no services), fall back to the prior
|
|
108
|
+
// visible step. Done in render to keep state derived + free of effects.
|
|
109
|
+
const stepIdx = (() => {
|
|
110
|
+
const idx = steps.findIndex((s) => s.id === stepId);
|
|
111
|
+
return idx >= 0 ? idx : 0;
|
|
112
|
+
})();
|
|
113
|
+
const step = steps[stepIdx];
|
|
114
|
+
const combinedOffer = useMemo(() => mergeOffers(selection), [selection]);
|
|
115
|
+
const ancillarySelection = useMemo(() => {
|
|
116
|
+
if (baggage.length === 0 &&
|
|
117
|
+
assistance.length === 0 &&
|
|
118
|
+
extras.length === 0 &&
|
|
119
|
+
seats.length === 0 &&
|
|
120
|
+
fareBundles.length === 0) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
...(baggage.length > 0 ? { baggage } : {}),
|
|
125
|
+
...(assistance.length > 0 ? { assistance } : {}),
|
|
126
|
+
...(extras.length > 0 ? { extras } : {}),
|
|
127
|
+
...(seats.length > 0 ? { seats } : {}),
|
|
128
|
+
...(fareBundles.length > 0 ? { fareBundle: fareBundles } : {}),
|
|
129
|
+
};
|
|
130
|
+
}, [baggage, assistance, extras, seats, fareBundles]);
|
|
131
|
+
const paxErrors = validatePassengers(paxList);
|
|
132
|
+
const billingError = validateBilling(billing);
|
|
133
|
+
// Per-leg ledger line items derived from picks + catalogs + seat picks.
|
|
134
|
+
const { outboundExtras, returnExtras } = useMemo(() => buildLedgerExtras({
|
|
135
|
+
baggage,
|
|
136
|
+
extras,
|
|
137
|
+
assistance,
|
|
138
|
+
seats,
|
|
139
|
+
fareBundles,
|
|
140
|
+
outboundOffer: selection.outbound,
|
|
141
|
+
returnOffer: selection.return,
|
|
142
|
+
outboundCatalog: ancillaries?.outboundCatalog ?? null,
|
|
143
|
+
returnCatalog: ancillaries?.returnCatalog ?? null,
|
|
144
|
+
seatMaps,
|
|
145
|
+
}), [
|
|
146
|
+
baggage,
|
|
147
|
+
extras,
|
|
148
|
+
assistance,
|
|
149
|
+
seats,
|
|
150
|
+
fareBundles,
|
|
151
|
+
selection.outbound,
|
|
152
|
+
selection.return,
|
|
153
|
+
ancillaries?.outboundCatalog,
|
|
154
|
+
ancillaries?.returnCatalog,
|
|
155
|
+
seatMaps,
|
|
156
|
+
]);
|
|
157
|
+
const completedSections = useMemo(() => {
|
|
158
|
+
const set = new Set();
|
|
159
|
+
set.add("flights");
|
|
160
|
+
// A section is "complete" once the user has navigated past it. We only
|
|
161
|
+
// mark sections that correspond to currently-visible steps.
|
|
162
|
+
const passedIdx = stepIdx;
|
|
163
|
+
const visibleIds = new Set(steps.map((s) => s.id));
|
|
164
|
+
const passed = (id) => {
|
|
165
|
+
if (!visibleIds.has(id))
|
|
166
|
+
return false;
|
|
167
|
+
const i = steps.findIndex((s) => s.id === id);
|
|
168
|
+
return i >= 0 && passedIdx > i;
|
|
169
|
+
};
|
|
170
|
+
if (passed("passengers"))
|
|
171
|
+
set.add("passengers");
|
|
172
|
+
if (passed("bags"))
|
|
173
|
+
set.add("bags");
|
|
174
|
+
if (passed("seats"))
|
|
175
|
+
set.add("seats");
|
|
176
|
+
if (passed("services"))
|
|
177
|
+
set.add("services");
|
|
178
|
+
if (passed("billing"))
|
|
179
|
+
set.add("billing");
|
|
180
|
+
if (stepIdx >= steps.length - 1)
|
|
181
|
+
set.add("payment");
|
|
182
|
+
return set;
|
|
183
|
+
}, [stepIdx, steps]);
|
|
184
|
+
if (!step)
|
|
185
|
+
return null;
|
|
186
|
+
const canContinue = (() => {
|
|
187
|
+
switch (step.id) {
|
|
188
|
+
case "review":
|
|
189
|
+
return true;
|
|
190
|
+
case "fares":
|
|
191
|
+
// Fare bundles are optional — keeping the base "Basic" fare is fine.
|
|
192
|
+
return true;
|
|
193
|
+
case "passengers":
|
|
194
|
+
return Object.keys(paxErrors).length === 0 && paxList.length > 0;
|
|
195
|
+
case "bags":
|
|
196
|
+
// Bags are optional — user may skip every passenger.
|
|
197
|
+
return true;
|
|
198
|
+
case "seats":
|
|
199
|
+
// Seats are optional under "skip" / "auto"; under "now" any picks are fine.
|
|
200
|
+
return true;
|
|
201
|
+
case "services":
|
|
202
|
+
return true;
|
|
203
|
+
case "billing":
|
|
204
|
+
return billingError == null;
|
|
205
|
+
case "payment":
|
|
206
|
+
return true;
|
|
207
|
+
case "confirm":
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
})();
|
|
211
|
+
const goNext = () => {
|
|
212
|
+
if (step?.id === "billing" && billing.saveAsDefault) {
|
|
213
|
+
onSaveBillingDefaults?.(billing);
|
|
214
|
+
}
|
|
215
|
+
const nextStep = steps[Math.min(steps.length - 1, stepIdx + 1)];
|
|
216
|
+
if (nextStep)
|
|
217
|
+
setStepId(nextStep.id);
|
|
218
|
+
};
|
|
219
|
+
const goBack = () => {
|
|
220
|
+
const prevStep = steps[Math.max(0, stepIdx - 1)];
|
|
221
|
+
if (prevStep)
|
|
222
|
+
setStepId(prevStep.id);
|
|
223
|
+
};
|
|
224
|
+
const submit = async () => {
|
|
225
|
+
setError(null);
|
|
226
|
+
setSubmitting(true);
|
|
227
|
+
try {
|
|
228
|
+
// Attach the structured billing address to the payment intent (when card)
|
|
229
|
+
// and surface the contact (email/phone) at the request level. Travel
|
|
230
|
+
// documents already live on each FlightPassenger.documents from the
|
|
231
|
+
// merged passenger form — no extra plumbing needed here.
|
|
232
|
+
const billingAddress = {
|
|
233
|
+
line1: billing.line1,
|
|
234
|
+
...(billing.line2 ? { line2: billing.line2 } : {}),
|
|
235
|
+
city: billing.city,
|
|
236
|
+
...(billing.region ? { region: billing.region } : {}),
|
|
237
|
+
...(billing.postalCode ? { postalCode: billing.postalCode } : {}),
|
|
238
|
+
countryCode: billing.countryCode,
|
|
239
|
+
};
|
|
240
|
+
const paymentWithAddress = payment.type === "card" ? { ...payment, billingAddress } : payment;
|
|
241
|
+
const order = await onBook({
|
|
242
|
+
offerId: combinedOffer.offerId,
|
|
243
|
+
offer: combinedOffer,
|
|
244
|
+
passengers: paxList,
|
|
245
|
+
contact: { email: billing.email, phone: billing.phone },
|
|
246
|
+
paymentIntent: paymentWithAddress,
|
|
247
|
+
...(ancillarySelection ? { ancillaries: ancillarySelection } : {}),
|
|
248
|
+
});
|
|
249
|
+
onBooked?.(order);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
setSubmitting(false);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 lg:grid lg:grid-cols-[1fr_360px] lg:items-start", children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-5", children: [_jsx(Stepper, { steps: steps, currentIdx: stepIdx }), error && (_jsx("div", { className: "rounded-md border border-destructive/40 bg-destructive/5 p-3 text-destructive text-sm", children: error })), _jsxs("div", { className: "flex flex-col gap-4", children: [step.id === "review" && (_jsx(ReviewStep, { selection: selection, carrierName: carrierName, airportName: airportName })), step.id === "fares" && (_jsx(FlightFareUpsellStep, { outboundOffer: selection.outbound, returnOffer: selection.return, passengers: paxList, passengerCounts: passengers, value: fareBundles, onChange: setFareBundles, sameForAllPassengers: sameFareForAllPax, onSameForAllPassengersChange: setSameFareForAllPax })), step.id === "passengers" && (_jsx(FlightPassengerForm, { counts: passengers, value: paxList, onChange: setPaxList, documentsRequired: documentsRequired, renderPicker: renderPassengerPicker })), step.id === "bags" && (_jsx(FlightBaggageStep, { outboundCatalog: ancillaries?.outboundCatalog ?? null, returnCatalog: ancillaries?.returnCatalog ?? null, outboundOffer: selection.outbound, returnOffer: selection.return, passengers: paxList, passengerCounts: passengers, value: baggage, onChange: setBaggage, sameForBothDirections: sameForBothDirections, onSameForBothDirectionsChange: setSameForBothDirections, loading: ancillaries?.loading })), step.id === "seats" && (_jsx(FlightSeatsStep, { outboundOffer: selection.outbound, returnOffer: selection.return, passengers: paxList, passengerCounts: passengers, value: seats, onChange: setSeats, mode: seatsMode, onModeChange: setSeatsMode, getSeatMap: seatMaps?.getSeatMap ?? (() => ({ seatMap: null, error: "Seat maps unavailable" })) })), step.id === "services" && (_jsx(FlightServicesStep, { outboundCatalog: ancillaries?.outboundCatalog ?? null, returnCatalog: ancillaries?.returnCatalog ?? null, outboundOffer: selection.outbound, returnOffer: selection.return, passengers: paxList, passengerCounts: passengers, assistance: assistance, extras: extras, onAssistanceChange: setAssistance, onExtrasChange: setExtras, loading: ancillaries?.loading })), step.id === "billing" && (_jsx(FlightBillingStep, { value: billing, onChange: setBilling, eligiblePassengers: paxList
|
|
259
|
+
.filter((p) => p.type === "adult" && p.firstName.trim() !== "" && p.lastName.trim() !== "")
|
|
260
|
+
.map((p) => ({
|
|
261
|
+
id: p.passengerId,
|
|
262
|
+
firstName: p.firstName,
|
|
263
|
+
...(p.middleName ? { middleName: p.middleName } : {}),
|
|
264
|
+
lastName: p.lastName,
|
|
265
|
+
})), renderPersonPicker: renderBillingPersonPicker, renderOrgPicker: renderBillingOrgPicker })), step.id === "payment" && (_jsx(FlightPaymentStep, { value: payment, onChange: setPayment, savedMethods: savedPaymentMethods?.methods ?? [], loadingSavedMethods: savedPaymentMethods?.loading, selectedSavedId: paymentSavedId, onSelectSaved: setPaymentSavedId, capabilities: paymentCapabilities })), step.id === "confirm" && (_jsx(ConfirmStep, { selection: selection, passengers: paxList, billing: billing, payment: payment, carrierName: carrierName, airportName: airportName }))] }), _jsxs("div", { className: "flex items-center justify-between border-t pt-4", children: [_jsxs(Button, { type: "button", variant: "ghost", onClick: () => (stepIdx === 0 ? onCancel?.() : goBack()), disabled: submitting, children: [_jsx(ChevronLeft, { className: "mr-1 h-4 w-4" }), stepIdx === 0 ? "Back to results" : "Back"] }), step.id === "confirm" ? (_jsx(Button, { onClick: submit, disabled: submitting, children: submitting ? "Booking…" : "Confirm booking" })) : (_jsxs(Button, { onClick: goNext, disabled: !canContinue, children: ["Continue", _jsx(ChevronRight, { className: "ml-1 h-4 w-4" })] }))] })] }), _jsx("div", { className: "lg:sticky lg:top-6", children: _jsx(FlightBookingLedger, { selection: selection, passengers: passengers, carrierName: carrierName, airportName: airportName, outboundExtras: outboundExtras, returnExtras: returnExtras, onEditOutbound: onEditOutbound, onEditReturn: onEditReturn, completedSections: completedSections }) })] }));
|
|
266
|
+
}
|
|
267
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
// Steps
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
function ReviewStep({ selection, carrierName, airportName, }) {
|
|
271
|
+
const isRoundTrip = !!selection.return;
|
|
272
|
+
return (_jsxs("div", { className: "flex flex-col gap-5 rounded-xl border bg-card p-5 shadow-sm", children: [_jsx("h2", { className: "font-semibold text-base", children: isRoundTrip ? "Review your trip" : "Review your flight" }), _jsx(FlightItinerary, { itinerary: selection.outbound.itineraries[0] ?? { segments: [] }, label: isRoundTrip ? "Outbound" : undefined, carrierName: carrierName, airportName: airportName }), selection.return && (_jsx(FlightItinerary, { itinerary: selection.return.itineraries[0] ?? { segments: [] }, label: "Return", carrierName: carrierName, airportName: airportName }))] }));
|
|
273
|
+
}
|
|
274
|
+
function ConfirmStep({ selection, passengers, billing, payment, carrierName, airportName, }) {
|
|
275
|
+
const docsCount = passengers.filter((p) => (p.documents?.length ?? 0) > 0).length;
|
|
276
|
+
const isRoundTrip = !!selection.return;
|
|
277
|
+
return (_jsxs("div", { className: "flex flex-col gap-5 rounded-xl border bg-card p-5 shadow-sm", children: [_jsx("h2", { className: "font-semibold text-base", children: "Confirm booking" }), _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(FlightItinerary, { itinerary: selection.outbound.itineraries[0] ?? { segments: [] }, label: isRoundTrip ? "Outbound" : undefined, compact: true, carrierName: carrierName, airportName: airportName }), selection.return && (_jsx(FlightItinerary, { itinerary: selection.return.itineraries[0] ?? { segments: [] }, label: "Return", compact: true, carrierName: carrierName, airportName: airportName }))] }), _jsx(Row, { label: "Passengers", children: passengers.length }), _jsx(Row, { label: "Documents", children: docsCount === passengers.length && passengers.length > 0
|
|
278
|
+
? `All ${docsCount} added`
|
|
279
|
+
: docsCount > 0
|
|
280
|
+
? `${docsCount} of ${passengers.length} added`
|
|
281
|
+
: "Add at check-in" }), _jsx(Row, { label: "Contact", children: billing.email || "—" }), _jsx(Row, { label: "Billed to", children: billing.mode === "company"
|
|
282
|
+
? `${billing.companyName ?? "—"} · ${billing.vatNumber ?? ""}`
|
|
283
|
+
: `${billing.firstName} ${billing.lastName}`.trim() || "—" }), _jsx(Row, { label: "Payment", children: _jsx("span", { className: "capitalize", children: payment.type.replace("_", " ") }) }), _jsx("p", { className: "text-muted-foreground text-xs", children: "Submitting will hold seats with the connector and (depending on the chosen payment intent) either issue tickets immediately or open a ticketing window. The booking will appear under the order id once confirmed." })] }));
|
|
284
|
+
}
|
|
285
|
+
function Row({ label, children }) {
|
|
286
|
+
return (_jsxs("div", { className: "flex items-baseline justify-between border-b py-2 text-sm last:border-b-0", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsx("span", { children: children })] }));
|
|
287
|
+
}
|
|
288
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
289
|
+
// Stepper
|
|
290
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
|
+
function Stepper({ steps, currentIdx }) {
|
|
292
|
+
return (_jsx("ol", { className: "flex items-center gap-2 overflow-x-auto", children: steps.map((s, i) => {
|
|
293
|
+
const isActive = i === currentIdx;
|
|
294
|
+
const isComplete = i < currentIdx;
|
|
295
|
+
return (_jsxs("li", { className: "flex flex-1 items-center gap-2", children: [_jsx("div", { className: cn("flex h-7 w-7 shrink-0 items-center justify-center rounded-full border font-medium text-xs tabular-nums", isComplete && "border-primary bg-primary text-primary-foreground", isActive && !isComplete && "border-primary text-primary", !isActive && !isComplete && "border-border text-muted-foreground"), children: isComplete ? _jsx(Check, { className: "h-3.5 w-3.5" }) : i + 1 }), _jsx("span", { className: cn("truncate text-sm", isActive ? "font-medium text-foreground" : "text-muted-foreground"), children: s.label }), i < steps.length - 1 && _jsx("div", { className: "h-px flex-1 bg-border" })] }, s.id));
|
|
296
|
+
}) }));
|
|
297
|
+
}
|
|
298
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
// Ledger line items from ancillary picks
|
|
300
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
function buildLedgerExtras({ baggage, extras, assistance, seats, fareBundles, outboundOffer, returnOffer, outboundCatalog, returnCatalog, seatMaps, }) {
|
|
302
|
+
const lines = (sliceIndex, catalog, offer) => {
|
|
303
|
+
const out = [];
|
|
304
|
+
// Fare-bundle picks (first so they sit at the top of the leg block).
|
|
305
|
+
// Per-pax per-leg picks are aggregated by bundleId here — when everyone
|
|
306
|
+
// is on the same fare we render "Standard fare · €36 (2 pax)"; mixed
|
|
307
|
+
// picks render as separate lines per bundle.
|
|
308
|
+
if (offer?.fareBundles) {
|
|
309
|
+
const legPicks = fareBundles.filter((p) => p.sliceIndex === sliceIndex);
|
|
310
|
+
const agg = new Map();
|
|
311
|
+
for (const p of legPicks) {
|
|
312
|
+
const bundle = offer.fareBundles.find((b) => b.id === p.bundleId);
|
|
313
|
+
if (!bundle)
|
|
314
|
+
continue;
|
|
315
|
+
const prev = agg.get(bundle.id);
|
|
316
|
+
if (prev) {
|
|
317
|
+
prev.count += 1;
|
|
318
|
+
prev.price += Number(bundle.priceDelta.amount);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
agg.set(bundle.id, {
|
|
322
|
+
count: 1,
|
|
323
|
+
label: bundle.label,
|
|
324
|
+
price: Number(bundle.priceDelta.amount),
|
|
325
|
+
currency: bundle.priceDelta.currency,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
for (const [, v] of agg) {
|
|
330
|
+
const labelSuffix = v.count > 1 ? ` (${v.count} pax)` : "";
|
|
331
|
+
out.push({
|
|
332
|
+
label: `${v.label} fare${labelSuffix}`,
|
|
333
|
+
amount: v.price > 0 ? { amount: v.price.toFixed(2), currency: v.currency } : undefined,
|
|
334
|
+
meta: v.price === 0 ? "Included" : undefined,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (catalog) {
|
|
339
|
+
// Baggage — group by option label, sum quantities + prices.
|
|
340
|
+
const bagPicks = baggage.filter((b) => b.sliceIndex === sliceIndex);
|
|
341
|
+
const bagAgg = new Map();
|
|
342
|
+
for (const p of bagPicks) {
|
|
343
|
+
const opt = catalog.baggage.find((o) => o.id === p.optionId);
|
|
344
|
+
if (!opt)
|
|
345
|
+
continue;
|
|
346
|
+
const k = opt.id;
|
|
347
|
+
const prev = bagAgg.get(k);
|
|
348
|
+
const qty = p.quantity ?? 1;
|
|
349
|
+
if (prev) {
|
|
350
|
+
prev.count += qty;
|
|
351
|
+
prev.price += Number(opt.price.amount) * qty;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
bagAgg.set(k, {
|
|
355
|
+
count: qty,
|
|
356
|
+
label: opt.label,
|
|
357
|
+
price: Number(opt.price.amount) * qty,
|
|
358
|
+
currency: opt.price.currency,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
for (const [, v] of bagAgg) {
|
|
363
|
+
out.push({
|
|
364
|
+
label: v.count > 1 ? `${v.count}× ${v.label}` : v.label,
|
|
365
|
+
amount: v.price > 0 ? { amount: v.price.toFixed(2), currency: v.currency } : undefined,
|
|
366
|
+
meta: v.price === 0 ? "Included" : undefined,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
// Extras — same aggregation.
|
|
370
|
+
const extraPicks = extras.filter((b) => b.sliceIndex === sliceIndex);
|
|
371
|
+
const extAgg = new Map();
|
|
372
|
+
for (const p of extraPicks) {
|
|
373
|
+
const opt = catalog.extras.find((o) => o.id === p.optionId);
|
|
374
|
+
if (!opt)
|
|
375
|
+
continue;
|
|
376
|
+
const qty = p.quantity ?? 1;
|
|
377
|
+
const prev = extAgg.get(opt.id);
|
|
378
|
+
if (prev) {
|
|
379
|
+
prev.count += qty;
|
|
380
|
+
prev.price += Number(opt.price.amount) * qty;
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
extAgg.set(opt.id, {
|
|
384
|
+
count: qty,
|
|
385
|
+
label: opt.label,
|
|
386
|
+
price: Number(opt.price.amount) * qty,
|
|
387
|
+
currency: opt.price.currency,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const [, v] of extAgg) {
|
|
392
|
+
out.push({
|
|
393
|
+
label: v.count > 1 ? `${v.count}× ${v.label}` : v.label,
|
|
394
|
+
amount: { amount: v.price.toFixed(2), currency: v.currency },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Seats — sum across all segments belonging to this leg's offer.
|
|
399
|
+
if (offer && seatMaps) {
|
|
400
|
+
const segIds = new Set();
|
|
401
|
+
for (const itin of offer.itineraries) {
|
|
402
|
+
for (const seg of itin.segments)
|
|
403
|
+
segIds.add(seg.segmentId);
|
|
404
|
+
}
|
|
405
|
+
const seatPicks = seats.filter((p) => segIds.has(p.segmentId));
|
|
406
|
+
if (seatPicks.length > 0) {
|
|
407
|
+
let total = 0;
|
|
408
|
+
let currency = "EUR";
|
|
409
|
+
for (const pick of seatPicks) {
|
|
410
|
+
const slot = seatMaps.getSeatMap({ offerId: offer.offerId, segmentId: pick.segmentId });
|
|
411
|
+
const seat = slot.seatMap ? findSeatInMap(slot.seatMap, pick.seatNumber) : null;
|
|
412
|
+
if (seat?.price) {
|
|
413
|
+
total += Number(seat.price.amount);
|
|
414
|
+
currency = seat.price.currency;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
out.push({
|
|
418
|
+
label: `${seatPicks.length} seat${seatPicks.length > 1 ? "s" : ""} picked`,
|
|
419
|
+
amount: total > 0 ? { amount: total.toFixed(2), currency } : undefined,
|
|
420
|
+
meta: total === 0 ? "Free" : undefined,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Assistance — only on the outbound block (it's trip-wide, not per leg).
|
|
425
|
+
if (sliceIndex === 0 && assistance.length > 0) {
|
|
426
|
+
out.push({
|
|
427
|
+
label: `Special assistance (${assistance.length})`,
|
|
428
|
+
meta: "Free",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
};
|
|
433
|
+
return {
|
|
434
|
+
outboundExtras: lines(0, outboundCatalog, outboundOffer),
|
|
435
|
+
returnExtras: lines(1, returnCatalog ?? outboundCatalog, returnOffer),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function findSeatInMap(map, seatNumber) {
|
|
439
|
+
for (const row of map.rows) {
|
|
440
|
+
for (const seat of row.seats) {
|
|
441
|
+
if (seat.seatNumber === seatNumber)
|
|
442
|
+
return seat;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
448
|
+
// Offer merging
|
|
449
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
450
|
+
/**
|
|
451
|
+
* Synthesize a single combined `FlightOffer` from a per-leg selection. This
|
|
452
|
+
* gives the booking adapter the same shape it would have received from a
|
|
453
|
+
* single round-trip search (one offer, multiple itineraries) — so the
|
|
454
|
+
* existing `bookFlight` contract works unchanged. The combined `offerId` is
|
|
455
|
+
* `<outboundId>+<returnId>` so it round-trips through caching cleanly.
|
|
456
|
+
*/
|
|
457
|
+
function mergeOffers(selection) {
|
|
458
|
+
const { outbound, return: ret } = selection;
|
|
459
|
+
if (!ret)
|
|
460
|
+
return outbound;
|
|
461
|
+
const currency = outbound.totalPrice.currency;
|
|
462
|
+
const amount = (Number(outbound.totalPrice.amount) + Number(ret.totalPrice.amount)).toFixed(2);
|
|
463
|
+
return {
|
|
464
|
+
offerId: `${outbound.offerId}+${ret.offerId}`,
|
|
465
|
+
source: outbound.source,
|
|
466
|
+
itineraries: [...outbound.itineraries, ...ret.itineraries],
|
|
467
|
+
fareBreakdowns: [...outbound.fareBreakdowns, ...ret.fareBreakdowns],
|
|
468
|
+
totalPrice: { amount, currency },
|
|
469
|
+
validatingCarrier: outbound.validatingCarrier,
|
|
470
|
+
expiresAt: pickEarliest(outbound.expiresAt, ret.expiresAt),
|
|
471
|
+
lastTicketingDate: pickEarliest(outbound.lastTicketingDate, ret.lastTicketingDate),
|
|
472
|
+
instantTicketing: (outbound.instantTicketing ?? false) && (ret.instantTicketing ?? false),
|
|
473
|
+
providerData: {
|
|
474
|
+
...(outbound.providerData ?? {}),
|
|
475
|
+
...(ret.providerData ?? {}),
|
|
476
|
+
__mergedFrom: { outbound: outbound.offerId, return: ret.offerId },
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function pickEarliest(a, b) {
|
|
481
|
+
if (!a)
|
|
482
|
+
return b;
|
|
483
|
+
if (!b)
|
|
484
|
+
return a;
|
|
485
|
+
return new Date(a).getTime() <= new Date(b).getTime() ? a : b;
|
|
486
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface FlightContactValue {
|
|
2
|
+
email?: string;
|
|
3
|
+
phone?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FlightContactFormProps {
|
|
6
|
+
value: FlightContactValue;
|
|
7
|
+
onChange: (next: FlightContactValue) => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Booking contact details — used by the connector to send confirmation +
|
|
11
|
+
* any operational disruption notices. Email is required by most providers;
|
|
12
|
+
* phone is recommended but optional.
|
|
13
|
+
*/
|
|
14
|
+
export declare function FlightContactForm({ value, onChange }: FlightContactFormProps): import("react/jsx-runtime").JSX.Element;
|
|
15
|
+
export declare function validateContact(value: FlightContactValue): string | null;
|
|
16
|
+
//# sourceMappingURL=flight-contact-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-contact-form.d.ts","sourceRoot":"","sources":["../../src/components/flight-contact-form.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,kBAAkB,CAAA;IACzB,QAAQ,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAA;CAC7C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CA4C5E;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,kBAAkB,GAAG,MAAM,GAAG,IAAI,CAKxE"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Input } from "@voyantjs/ui/components/input";
|
|
4
|
+
import { Label } from "@voyantjs/ui/components/label";
|
|
5
|
+
import { Mail, Phone } from "lucide-react";
|
|
6
|
+
/**
|
|
7
|
+
* Booking contact details — used by the connector to send confirmation +
|
|
8
|
+
* any operational disruption notices. Email is required by most providers;
|
|
9
|
+
* phone is recommended but optional.
|
|
10
|
+
*/
|
|
11
|
+
export function FlightContactForm({ value, onChange }) {
|
|
12
|
+
return (_jsxs("div", { className: "rounded-lg border bg-card p-4 shadow-sm", children: [_jsx("h3", { className: "mb-3 font-medium text-sm", children: "Contact details" }), _jsx("p", { className: "mb-4 text-xs text-muted-foreground", children: "Used by the airline to send confirmation, schedule changes, and operational notices for this booking." }), _jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-[11px] uppercase tracking-wider text-muted-foreground", children: ["Email ", _jsx("span", { className: "ml-0.5 text-destructive", children: "*" })] }), _jsxs("div", { className: "relative", children: [_jsx(Mail, { className: "-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" }), _jsx(Input, { type: "email", autoComplete: "email", value: value.email ?? "", onChange: (e) => onChange({ ...value, email: e.target.value }), placeholder: "traveler@example.com", className: "pl-9" })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { className: "text-[11px] uppercase tracking-wider text-muted-foreground", children: "Phone" }), _jsxs("div", { className: "relative", children: [_jsx(Phone, { className: "-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" }), _jsx(Input, { type: "tel", autoComplete: "tel", value: value.phone ?? "", onChange: (e) => onChange({ ...value, phone: e.target.value }), placeholder: "+1 555 123 4567", className: "pl-9" })] })] })] })] }));
|
|
13
|
+
}
|
|
14
|
+
export function validateContact(value) {
|
|
15
|
+
if (!value.email?.trim())
|
|
16
|
+
return "Email is required";
|
|
17
|
+
// Loose email check — adapters do their own validation
|
|
18
|
+
if (!/.+@.+\..+/.test(value.email))
|
|
19
|
+
return "Email looks invalid";
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AncillarySelection, FlightOffer, FlightPassenger, PassengerCounts } from "@voyantjs/flights/contract/types";
|
|
2
|
+
type FareBundlePicks = NonNullable<AncillarySelection["fareBundle"]>;
|
|
3
|
+
export interface FlightFareUpsellStepProps {
|
|
4
|
+
outboundOffer: FlightOffer;
|
|
5
|
+
returnOffer?: FlightOffer;
|
|
6
|
+
/** Filled passenger entries — used for per-pax labels. */
|
|
7
|
+
passengers: FlightPassenger[];
|
|
8
|
+
/** Pax counts when the form hasn't been filled yet (fallback labels). */
|
|
9
|
+
passengerCounts: PassengerCounts;
|
|
10
|
+
value: FareBundlePicks;
|
|
11
|
+
onChange: (next: FareBundlePicks) => void;
|
|
12
|
+
/** Default-on toggle: one pick applies to every passenger on the leg. */
|
|
13
|
+
sameForAllPassengers: boolean;
|
|
14
|
+
onSameForAllPassengersChange: (next: boolean) => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Per-pax per-leg branded-fare upsell step. For multi-pax bookings the
|
|
18
|
+
* "Same fare for all passengers" toggle (default ON) collapses the picker
|
|
19
|
+
* back to one card grid per leg, keeping the common case ("everyone on
|
|
20
|
+
* Standard") to one click. Toggling off splits each leg into per-pax card
|
|
21
|
+
* grids — useful for the "Adult 1 Plus, Adult 2 Basic" case full-service
|
|
22
|
+
* carriers + B2B agency bookings actually exercise.
|
|
23
|
+
*/
|
|
24
|
+
export declare function FlightFareUpsellStep({ outboundOffer, returnOffer, passengers, passengerCounts, value, onChange, sameForAllPassengers, onSameForAllPassengersChange, }: FlightFareUpsellStepProps): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=flight-fare-upsell-step.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-fare-upsell-step.d.ts","sourceRoot":"","sources":["../../src/components/flight-fare-upsell-step.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAElB,WAAW,EACX,eAAe,EACf,eAAe,EAChB,MAAM,kCAAkC,CAAA;AAMzC,KAAK,eAAe,GAAG,WAAW,CAAC,kBAAkB,CAAC,YAAY,CAAC,CAAC,CAAA;AAGpE,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,0DAA0D;IAC1D,UAAU,EAAE,eAAe,EAAE,CAAA;IAC7B,yEAAyE;IACzE,eAAe,EAAE,eAAe,CAAA;IAChC,KAAK,EAAE,eAAe,CAAA;IACtB,QAAQ,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;IACzC,yEAAyE;IACzE,oBAAoB,EAAE,OAAO,CAAA;IAC7B,4BAA4B,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;CACtD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,aAAa,EACb,WAAW,EACX,UAAU,EACV,eAAe,EACf,KAAK,EACL,QAAQ,EACR,oBAAoB,EACpB,4BAA4B,GAC7B,EAAE,yBAAyB,2CA8F3B"}
|