fyliu Claude Opus 4.6 commited on
Commit
470054e
·
1 Parent(s): 73a6301

Add booking options page with cabin-aware fare tiers

Browse files

Insert a fare selection step between flight results and payment.
Shows 2-4 fare tiers (e.g. Basic Economy / Economy / Economy Flex
+ Premium Economy upgrade) with per-tier features like seat
selection, legroom, priority boarding, ticket flexibility, carry-on,
and checked bags. Business/First show lie-flat seats and 2pc bags.
Align all ports to 7860 for HF Spaces compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

docker-compose.yml CHANGED
@@ -2,5 +2,5 @@ services:
2
  flight-search:
3
  build: .
4
  ports:
5
- - "8080:8080"
6
  restart: unless-stopped
 
2
  flight-search:
3
  build: .
4
  ports:
5
+ - "7860:7860"
6
  restart: unless-stopped
frontend/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import Header from './components/shared/Header';
4
  import PasskeyGate from './components/shared/PasskeyGate';
5
  import SearchPage from './pages/SearchPage';
6
  import ResultsPage from './pages/ResultsPage';
 
7
  import BookingPage from './pages/BookingPage';
8
  import ConfirmationPage from './pages/ConfirmationPage';
9
 
@@ -31,6 +32,7 @@ export default function App() {
31
  <Routes>
32
  <Route path="/" element={<SearchPage />} />
33
  <Route path="/results" element={<ResultsPage />} />
 
34
  <Route path="/booking" element={<BookingPage />} />
35
  <Route path="/confirmation" element={<ConfirmationPage />} />
36
  </Routes>
 
4
  import PasskeyGate from './components/shared/PasskeyGate';
5
  import SearchPage from './pages/SearchPage';
6
  import ResultsPage from './pages/ResultsPage';
7
+ import BookingOptionsPage from './pages/BookingOptionsPage';
8
  import BookingPage from './pages/BookingPage';
9
  import ConfirmationPage from './pages/ConfirmationPage';
10
 
 
32
  <Routes>
33
  <Route path="/" element={<SearchPage />} />
34
  <Route path="/results" element={<ResultsPage />} />
35
+ <Route path="/booking-options" element={<BookingOptionsPage />} />
36
  <Route path="/booking" element={<BookingPage />} />
37
  <Route path="/confirmation" element={<ConfirmationPage />} />
38
  </Routes>
frontend/src/pages/BookingOptionsPage.tsx ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { useLocation, useNavigate } from 'react-router-dom';
3
+ import type { FlightOffer } from '../api/types';
4
+ import { formatDate, formatDuration, formatPrice, formatStops, formatTime } from '../utils/format';
5
+
6
+ interface LocationState {
7
+ outboundFlight: FlightOffer;
8
+ returnFlight?: FlightOffer;
9
+ }
10
+
11
+ type FeatureStatus = 'included' | 'paid' | 'none';
12
+
13
+ interface FeatureEntry {
14
+ status: FeatureStatus;
15
+ label: string;
16
+ }
17
+
18
+ interface TierConfig {
19
+ key: string;
20
+ label: string;
21
+ description: string;
22
+ priceMultiplier: number;
23
+ features: FeatureEntry[];
24
+ isUpgrade?: boolean;
25
+ }
26
+
27
+ interface CabinConfig {
28
+ tiers: TierConfig[];
29
+ featureCount: number;
30
+ }
31
+
32
+ // Feature slot order: seat_selection, seat_comfort, priority_boarding,
33
+ // ticket_changes, ticket_cancellation, carry_on, checked_bag
34
+
35
+ const ECONOMY_CONFIG: CabinConfig = {
36
+ featureCount: 7,
37
+ tiers: [
38
+ {
39
+ key: 'basic', label: 'Basic Economy', description: 'Most restrictions apply', priceMultiplier: 1.0,
40
+ features: [
41
+ { status: 'paid', label: 'Seat selection' },
42
+ { status: 'none', label: 'Extra legroom' },
43
+ { status: 'none', label: 'Priority boarding' },
44
+ { status: 'none', label: 'Ticket changes' },
45
+ { status: 'none', label: 'Ticket cancellation' },
46
+ { status: 'none', label: 'Carry-on' },
47
+ { status: 'paid', label: '1 pc checked bag' },
48
+ ],
49
+ },
50
+ {
51
+ key: 'standard', label: 'Economy', description: 'Standard ticket with flexibility', priceMultiplier: 1.12,
52
+ features: [
53
+ { status: 'included', label: 'Seat selection' },
54
+ { status: 'paid', label: 'Extra legroom' },
55
+ { status: 'none', label: 'Priority boarding' },
56
+ { status: 'paid', label: 'Ticket changes' },
57
+ { status: 'paid', label: 'Ticket cancellation' },
58
+ { status: 'included', label: 'Carry-on' },
59
+ { status: 'paid', label: '1 pc checked bag' },
60
+ ],
61
+ },
62
+ {
63
+ key: 'flex', label: 'Economy Flex', description: 'Full flexibility included', priceMultiplier: 1.32,
64
+ features: [
65
+ { status: 'included', label: 'Seat selection' },
66
+ { status: 'included', label: 'Extra legroom' },
67
+ { status: 'included', label: 'Priority boarding' },
68
+ { status: 'included', label: 'Free ticket changes' },
69
+ { status: 'included', label: 'Free ticket cancellation' },
70
+ { status: 'included', label: 'Carry-on' },
71
+ { status: 'included', label: '1 pc checked bag' },
72
+ ],
73
+ },
74
+ ],
75
+ };
76
+
77
+ const PREMIUM_ECONOMY_CONFIG: CabinConfig = {
78
+ featureCount: 7,
79
+ tiers: [
80
+ {
81
+ key: 'standard', label: 'Premium Economy', description: 'Standard ticket with comfort', priceMultiplier: 1.0,
82
+ features: [
83
+ { status: 'included', label: 'Seat selection' },
84
+ { status: 'included', label: 'Extra legroom' },
85
+ { status: 'included', label: 'Priority boarding' },
86
+ { status: 'paid', label: 'Ticket changes' },
87
+ { status: 'paid', label: 'Ticket cancellation' },
88
+ { status: 'included', label: 'Carry-on' },
89
+ { status: 'included', label: '1 pc checked bag' },
90
+ ],
91
+ },
92
+ {
93
+ key: 'flex', label: 'Premium Economy Flex', description: 'Full flexibility included', priceMultiplier: 1.18,
94
+ features: [
95
+ { status: 'included', label: 'Seat selection' },
96
+ { status: 'included', label: 'Extra legroom' },
97
+ { status: 'included', label: 'Priority boarding' },
98
+ { status: 'included', label: 'Free ticket changes' },
99
+ { status: 'included', label: 'Free ticket cancellation' },
100
+ { status: 'included', label: 'Carry-on' },
101
+ { status: 'included', label: '1 pc checked bag' },
102
+ ],
103
+ },
104
+ ],
105
+ };
106
+
107
+ const BUSINESS_CONFIG: CabinConfig = {
108
+ featureCount: 7,
109
+ tiers: [
110
+ {
111
+ key: 'standard', label: 'Business', description: 'Standard business fare', priceMultiplier: 1.0,
112
+ features: [
113
+ { status: 'included', label: 'Seat selection' },
114
+ { status: 'included', label: 'Lie-flat seat' },
115
+ { status: 'included', label: 'Priority boarding' },
116
+ { status: 'paid', label: 'Ticket changes' },
117
+ { status: 'paid', label: 'Ticket cancellation' },
118
+ { status: 'included', label: 'Carry-on' },
119
+ { status: 'included', label: '2 pc checked bag' },
120
+ ],
121
+ },
122
+ {
123
+ key: 'flex', label: 'Business Flex', description: 'Full flexibility included', priceMultiplier: 1.15,
124
+ features: [
125
+ { status: 'included', label: 'Seat selection' },
126
+ { status: 'included', label: 'Lie-flat seat' },
127
+ { status: 'included', label: 'Priority boarding' },
128
+ { status: 'included', label: 'Free ticket changes' },
129
+ { status: 'included', label: 'Free ticket cancellation' },
130
+ { status: 'included', label: 'Carry-on' },
131
+ { status: 'included', label: '2 pc checked bag' },
132
+ ],
133
+ },
134
+ ],
135
+ };
136
+
137
+ const FIRST_CONFIG: CabinConfig = {
138
+ featureCount: 7,
139
+ tiers: [
140
+ {
141
+ key: 'standard', label: 'First', description: 'Standard first class fare', priceMultiplier: 1.0,
142
+ features: [
143
+ { status: 'included', label: 'Seat selection' },
144
+ { status: 'included', label: 'Lie-flat seat' },
145
+ { status: 'included', label: 'Priority boarding' },
146
+ { status: 'paid', label: 'Ticket changes' },
147
+ { status: 'paid', label: 'Ticket cancellation' },
148
+ { status: 'included', label: 'Carry-on' },
149
+ { status: 'included', label: '2 pc checked bag' },
150
+ ],
151
+ },
152
+ {
153
+ key: 'flex', label: 'First Flex', description: 'Full flexibility included', priceMultiplier: 1.12,
154
+ features: [
155
+ { status: 'included', label: 'Seat selection' },
156
+ { status: 'included', label: 'Lie-flat seat' },
157
+ { status: 'included', label: 'Priority boarding' },
158
+ { status: 'included', label: 'Free ticket changes' },
159
+ { status: 'included', label: 'Free ticket cancellation' },
160
+ { status: 'included', label: 'Carry-on' },
161
+ { status: 'included', label: '2 pc checked bag' },
162
+ ],
163
+ },
164
+ ],
165
+ };
166
+
167
+ const CABIN_ORDER: { key: string; config: CabinConfig; upgradeMultiplier: number }[] = [
168
+ { key: 'economy', config: ECONOMY_CONFIG, upgradeMultiplier: 1.8 },
169
+ { key: 'premium_economy', config: PREMIUM_ECONOMY_CONFIG, upgradeMultiplier: 2.2 },
170
+ { key: 'business', config: BUSINESS_CONFIG, upgradeMultiplier: 1.6 },
171
+ { key: 'first', config: FIRST_CONFIG, upgradeMultiplier: 1.0 },
172
+ ];
173
+
174
+ function getCabinConfig(cabinClass: string): CabinConfig {
175
+ const idx = CABIN_ORDER.findIndex(c => c.key === cabinClass);
176
+ const current = idx >= 0 ? CABIN_ORDER[idx] : CABIN_ORDER[0];
177
+ const next = idx >= 0 && idx < CABIN_ORDER.length - 1 ? CABIN_ORDER[idx + 1] : null;
178
+
179
+ if (!next) return current.config;
180
+
181
+ // Take the first (standard) tier from the next cabin as the upsell
182
+ const upgradeTier = next.config.tiers[0];
183
+ const upsellTier: TierConfig = {
184
+ ...upgradeTier,
185
+ key: `upgrade_${upgradeTier.key}`,
186
+ priceMultiplier: current.upgradeMultiplier,
187
+ isUpgrade: true,
188
+ };
189
+
190
+ return {
191
+ featureCount: current.config.featureCount,
192
+ tiers: [...current.config.tiers, upsellTier],
193
+ };
194
+ }
195
+
196
+ function CheckIcon() {
197
+ return (
198
+ <svg className="h-5 w-5 text-[#1a73e8]" viewBox="0 0 20 20" fill="currentColor">
199
+ <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
200
+ </svg>
201
+ );
202
+ }
203
+
204
+ function PaidIcon() {
205
+ return (
206
+ <svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
207
+ <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v1H8a1 1 0 100 2h1v1H8a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V9h1a1 1 0 100-2h-1V6z" clipRule="evenodd" />
208
+ </svg>
209
+ );
210
+ }
211
+
212
+ function CrossIcon() {
213
+ return (
214
+ <svg className="h-5 w-5 text-gray-300" viewBox="0 0 20 20" fill="currentColor">
215
+ <path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
216
+ </svg>
217
+ );
218
+ }
219
+
220
+ function FeatureStatusIcon({ status }: { status: FeatureStatus }) {
221
+ if (status === 'included') return <CheckIcon />;
222
+ if (status === 'paid') return <PaidIcon />;
223
+ return <CrossIcon />;
224
+ }
225
+
226
+ function FlightSummaryRow({ label, flight }: { label: string; flight: FlightOffer }) {
227
+ const firstSeg = flight.segments[0];
228
+ const lastSeg = flight.segments[flight.segments.length - 1];
229
+ return (
230
+ <div className="flex items-center gap-4 py-3">
231
+ <div className="flex h-8 w-8 items-center justify-center rounded bg-gray-100 text-[10px] font-bold text-gray-600 flex-shrink-0">
232
+ {firstSeg.airline_code}
233
+ </div>
234
+ <div className="flex-1 min-w-0">
235
+ <div className="flex items-center gap-2 text-sm">
236
+ <span className="font-medium text-gray-900">{formatTime(flight.departure)}</span>
237
+ <span className="text-gray-400">&ndash;</span>
238
+ <span className="font-medium text-gray-900">{formatTime(flight.arrival)}</span>
239
+ <span className="text-gray-300 text-xs">·</span>
240
+ <span className="text-xs text-gray-500">{formatDuration(flight.total_duration_minutes)}</span>
241
+ <span className="text-gray-300 text-xs">·</span>
242
+ <span className="text-xs text-gray-500">{formatStops(flight.stops)}</span>
243
+ </div>
244
+ <div className="text-xs text-gray-500 mt-0.5">
245
+ {firstSeg.origin_city} ({flight.origin}) &rarr; {lastSeg.destination_city} ({flight.destination})
246
+ <span className="text-gray-300 mx-1">·</span>
247
+ {formatDate(flight.departure)}
248
+ <span className="text-gray-300 mx-1">·</span>
249
+ {firstSeg.airline_name}
250
+ </div>
251
+ </div>
252
+ <div className="text-xs font-medium text-gray-400 uppercase tracking-wide flex-shrink-0">
253
+ {label}
254
+ </div>
255
+ </div>
256
+ );
257
+ }
258
+
259
+ export default function BookingOptionsPage() {
260
+ const location = useLocation();
261
+ const navigate = useNavigate();
262
+ const state = location.state as LocationState | null;
263
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
264
+
265
+ if (!state?.outboundFlight) {
266
+ return (
267
+ <div className="mx-auto max-w-3xl px-4 py-12 text-center">
268
+ <h1 className="text-xl font-semibold text-gray-900 mb-2">No flight selected</h1>
269
+ <p className="text-gray-500 mb-4">Please search for flights and select one to book.</p>
270
+ <button onClick={() => navigate('/')} className="text-[#1a73e8] hover:underline cursor-pointer">
271
+ Back to search
272
+ </button>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ const { outboundFlight, returnFlight } = state;
278
+ const cabinClass = outboundFlight.cabin_class;
279
+ const config = getCabinConfig(cabinClass);
280
+ const basePrice = outboundFlight.price_usd + (returnFlight?.price_usd || 0);
281
+
282
+ // Default to the first non-basic tier, or the first tier
283
+ const defaultIndex = config.tiers.length > 2 ? 1 : 0;
284
+ const selected = selectedIndex ?? defaultIndex;
285
+ const selectedTier = config.tiers[selected];
286
+
287
+ const tierPrices = config.tiers.map(t => Math.round(basePrice * t.priceMultiplier));
288
+ const lowestPrice = tierPrices[0];
289
+
290
+ function handleContinue() {
291
+ navigate('/booking', {
292
+ state: {
293
+ outboundFlight,
294
+ returnFlight,
295
+ selectedFare: {
296
+ tier: selectedTier.key,
297
+ label: selectedTier.label,
298
+ totalPrice: tierPrices[selected],
299
+ },
300
+ },
301
+ });
302
+ }
303
+
304
+ const tierCount = config.tiers.length;
305
+ const gridCols = tierCount === 4 ? 'grid-cols-4' : 'grid-cols-3';
306
+
307
+ return (
308
+ <div className="min-h-screen bg-gray-50">
309
+ <div className={`mx-auto px-4 py-8 ${tierCount === 4 ? 'max-w-5xl' : 'max-w-4xl'}`}>
310
+ {/* Back button */}
311
+ <button
312
+ onClick={() => navigate(-1)}
313
+ className="flex items-center gap-1 text-sm text-[#1a73e8] hover:underline cursor-pointer mb-6"
314
+ >
315
+ <svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
316
+ <path fillRule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clipRule="evenodd" />
317
+ </svg>
318
+ Back to flights
319
+ </button>
320
+
321
+ {/* Flight summary */}
322
+ <div className="rounded-lg border border-gray-200 bg-white p-5 mb-6">
323
+ <h2 className="text-lg font-medium text-gray-900 mb-1">Selected flights</h2>
324
+ <div className="divide-y divide-gray-100">
325
+ <FlightSummaryRow label="Departs" flight={outboundFlight} />
326
+ {returnFlight && <FlightSummaryRow label="Returns" flight={returnFlight} />}
327
+ </div>
328
+ </div>
329
+
330
+ {/* Fare options */}
331
+ <h1 className="text-xl font-semibold text-gray-900 mb-2">Choose your booking option</h1>
332
+ <p className="text-sm text-gray-500 mb-5">
333
+ Select a fare type. Prices shown are per person for the entire trip.
334
+ </p>
335
+
336
+ {/* Fare columns */}
337
+ <div className={`grid ${gridCols} gap-4 mb-6`}>
338
+ {config.tiers.map((tier, i) => {
339
+ const isSelected = selected === i;
340
+ const price = tierPrices[i];
341
+ const priceDiff = price - lowestPrice;
342
+
343
+ return (
344
+ <button
345
+ key={tier.key}
346
+ onClick={() => setSelectedIndex(i)}
347
+ className={`rounded-xl border-2 text-left transition-all cursor-pointer ${
348
+ tier.isUpgrade ? 'bg-amber-50/50' : 'bg-white'
349
+ } ${
350
+ isSelected
351
+ ? 'border-[#1a73e8] shadow-md'
352
+ : tier.isUpgrade
353
+ ? 'border-amber-200 hover:border-amber-300 hover:shadow-sm'
354
+ : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
355
+ }`}
356
+ >
357
+ {/* Upgrade badge */}
358
+ {tier.isUpgrade && (
359
+ <div className="px-5 pt-3 pb-0">
360
+ <span className="inline-block rounded-full bg-amber-100 px-2.5 py-0.5 text-[11px] font-semibold text-amber-700 uppercase tracking-wide">
361
+ Upgrade
362
+ </span>
363
+ </div>
364
+ )}
365
+
366
+ {/* Tier header */}
367
+ <div className={`px-5 ${tier.isUpgrade ? 'pt-2' : 'pt-5'} pb-4 border-b ${isSelected ? 'border-blue-100' : tier.isUpgrade ? 'border-amber-100' : 'border-gray-100'}`}>
368
+ <div className="flex items-center gap-2 mb-1">
369
+ <div className={`h-4 w-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
370
+ isSelected ? 'border-[#1a73e8]' : 'border-gray-300'
371
+ }`}>
372
+ {isSelected && <div className="h-2 w-2 rounded-full bg-[#1a73e8]" />}
373
+ </div>
374
+ <span className="text-sm font-semibold text-gray-900">{tier.label}</span>
375
+ </div>
376
+ <div className="ml-6">
377
+ <div className="text-xl font-semibold text-gray-900">{formatPrice(price)}</div>
378
+ {priceDiff > 0 && (
379
+ <div className="text-xs text-gray-400 mt-0.5">+{formatPrice(priceDiff)}</div>
380
+ )}
381
+ <div className="text-xs text-gray-500 mt-1">{tier.description}</div>
382
+ </div>
383
+ </div>
384
+
385
+ {/* Features list */}
386
+ <div className="px-5 py-4 space-y-3">
387
+ {tier.features.slice(0, config.featureCount).map((feature, fi) => (
388
+ <div key={fi} className="flex items-center gap-2.5">
389
+ <div className="flex-shrink-0 w-5 flex items-center justify-center">
390
+ <FeatureStatusIcon status={feature.status} />
391
+ </div>
392
+ <span className={`text-sm ${feature.status === 'none' ? 'text-gray-400' : 'text-gray-700'}`}>
393
+ {feature.label}
394
+ {feature.status === 'paid' && <span className="text-xs text-gray-400 ml-1">· Fee</span>}
395
+ </span>
396
+ </div>
397
+ ))}
398
+ </div>
399
+ </button>
400
+ );
401
+ })}
402
+ </div>
403
+
404
+ {/* Bottom bar */}
405
+ <div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-6 py-4">
406
+ <div>
407
+ <div className="text-sm text-gray-500">
408
+ {selectedTier.label} &middot; {returnFlight ? 'Round trip' : 'One way'}
409
+ </div>
410
+ <div className="text-xl font-semibold text-gray-900">
411
+ {formatPrice(tierPrices[selected])}
412
+ </div>
413
+ </div>
414
+ <button
415
+ onClick={handleContinue}
416
+ className="rounded-full bg-[#1a73e8] px-8 py-2.5 text-sm font-medium text-white hover:bg-[#1557b0] cursor-pointer transition-colors"
417
+ >
418
+ Continue to booking
419
+ </button>
420
+ </div>
421
+ </div>
422
+ </div>
423
+ );
424
+ }
frontend/src/pages/BookingPage.tsx CHANGED
@@ -4,9 +4,16 @@ import type { FlightOffer } from '../api/types';
4
  import { bookFlight } from '../api/client';
5
  import { formatDate, formatDuration, formatPrice, formatStops, formatTime } from '../utils/format';
6
 
 
 
 
 
 
 
7
  interface LocationState {
8
  outboundFlight: FlightOffer;
9
  returnFlight?: FlightOffer;
 
10
  }
11
 
12
  function FlightSummaryCard({ label, flight }: { label: string; flight: FlightOffer }) {
@@ -66,8 +73,8 @@ export default function BookingPage() {
66
  );
67
  }
68
 
69
- const { outboundFlight, returnFlight } = state;
70
- const totalPrice = outboundFlight.price_usd + (returnFlight?.price_usd || 0);
71
 
72
  async function handleSubmit(e: React.FormEvent) {
73
  e.preventDefault();
@@ -97,6 +104,20 @@ export default function BookingPage() {
97
  <div className="space-y-3 mb-8">
98
  <FlightSummaryCard label="Outbound flight" flight={outboundFlight} />
99
  {returnFlight && <FlightSummaryCard label="Return flight" flight={returnFlight} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  <div className="text-right text-lg font-semibold text-gray-900">
101
  Total: {formatPrice(totalPrice)}
102
  </div>
 
4
  import { bookFlight } from '../api/client';
5
  import { formatDate, formatDuration, formatPrice, formatStops, formatTime } from '../utils/format';
6
 
7
+ interface SelectedFare {
8
+ tier: string;
9
+ label: string;
10
+ totalPrice: number;
11
+ }
12
+
13
  interface LocationState {
14
  outboundFlight: FlightOffer;
15
  returnFlight?: FlightOffer;
16
+ selectedFare?: SelectedFare;
17
  }
18
 
19
  function FlightSummaryCard({ label, flight }: { label: string; flight: FlightOffer }) {
 
73
  );
74
  }
75
 
76
+ const { outboundFlight, returnFlight, selectedFare } = state;
77
+ const totalPrice = selectedFare?.totalPrice ?? (outboundFlight.price_usd + (returnFlight?.price_usd || 0));
78
 
79
  async function handleSubmit(e: React.FormEvent) {
80
  e.preventDefault();
 
104
  <div className="space-y-3 mb-8">
105
  <FlightSummaryCard label="Outbound flight" flight={outboundFlight} />
106
  {returnFlight && <FlightSummaryCard label="Return flight" flight={returnFlight} />}
107
+ {selectedFare && (
108
+ <div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 flex items-center justify-between">
109
+ <div className="text-sm text-gray-700">
110
+ <span className="font-medium">{selectedFare.label}</span>
111
+ <span className="text-gray-400 ml-1">fare selected</span>
112
+ </div>
113
+ <button
114
+ onClick={() => navigate(-1)}
115
+ className="text-xs text-[#1a73e8] hover:underline cursor-pointer"
116
+ >
117
+ Change
118
+ </button>
119
+ </div>
120
+ )}
121
  <div className="text-right text-lg font-semibold text-gray-900">
122
  Total: {formatPrice(totalPrice)}
123
  </div>
frontend/src/pages/ResultsPage.tsx CHANGED
@@ -185,11 +185,11 @@ export default function ResultsPage() {
185
  }
186
 
187
  function handleBookOneWay(flight: FlightOffer) {
188
- navigate('/booking', { state: { outboundFlight: flight } });
189
  }
190
 
191
  function handleBookReturn(returnFlight: FlightOffer) {
192
- navigate('/booking', { state: { outboundFlight: selectedOutbound, returnFlight } });
193
  }
194
 
195
  return (
 
185
  }
186
 
187
  function handleBookOneWay(flight: FlightOffer) {
188
+ navigate('/booking-options', { state: { outboundFlight: flight } });
189
  }
190
 
191
  function handleBookReturn(returnFlight: FlightOffer) {
192
+ navigate('/booking-options', { state: { outboundFlight: selectedOutbound, returnFlight } });
193
  }
194
 
195
  return (
frontend/vite.config.ts CHANGED
@@ -6,7 +6,7 @@ export default defineConfig({
6
  plugins: [react(), tailwindcss()],
7
  server: {
8
  proxy: {
9
- '/api': 'http://localhost:8080',
10
  },
11
  },
12
  })
 
6
  plugins: [react(), tailwindcss()],
7
  server: {
8
  proxy: {
9
+ '/api': 'http://localhost:7860',
10
  },
11
  },
12
  })