fyliu Claude Opus 4.6 commited on
Commit
d6f7eeb
·
1 Parent(s): 334c128

Add flight booking flow with payment validation

Browse files

- Backend: booking store with cardholder-based payment validation,
generic error messages, flexible expiry format (1227 / 12/27)
- Frontend: BookingPage, ConfirmationPage, booking API client
- Wire up Book button in FlightCard for one-way and round-trip flows
- Also includes short-distance surcharge and advance pricing tweaks

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

.gitignore CHANGED
@@ -6,3 +6,4 @@ __pycache__/
6
  *.pyc
7
  .uv_cache/
8
  .uv_pythons/
 
 
6
  *.pyc
7
  .uv_cache/
8
  .uv_pythons/
9
+ flight-search-arm64.tar
backend/api/booking.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Booking API — validate payment and create bookings."""
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from ..booking_store import create_booking, get_all_bookings, validate_payment
6
+ from ..models import BookingRequest, BookingResponse
7
+
8
+ router = APIRouter(prefix="/api", tags=["booking"])
9
+
10
+
11
+ @router.post("/booking", response_model=BookingResponse)
12
+ async def book_flight(req: BookingRequest):
13
+ """Validate payment and create a confirmed booking."""
14
+ ok, error_msg, card_type = validate_payment(
15
+ cardholder_name=req.payment.cardholder_name,
16
+ card_number=req.payment.card_number,
17
+ expiry=req.payment.expiry_date,
18
+ cvv=req.payment.cvv,
19
+ )
20
+ if not ok:
21
+ raise HTTPException(status_code=400, detail=error_msg)
22
+
23
+ total_price = req.outbound_flight.price_usd
24
+ if req.return_flight:
25
+ total_price += req.return_flight.price_usd
26
+
27
+ # Mask card number for response
28
+ masked_card = "****" + req.payment.card_number[-4:]
29
+
30
+ booking = create_booking({
31
+ "passenger": req.passenger.model_dump(),
32
+ "payment_summary": {
33
+ "card_type": card_type,
34
+ "masked_card": masked_card,
35
+ "cardholder_name": req.payment.cardholder_name,
36
+ },
37
+ "outbound_flight": req.outbound_flight.model_dump(mode="json"),
38
+ "return_flight": req.return_flight.model_dump(mode="json") if req.return_flight else None,
39
+ "total_price": total_price,
40
+ })
41
+ return booking
42
+
43
+
44
+ @router.get("/bookings")
45
+ async def list_bookings():
46
+ """List all confirmed bookings."""
47
+ return get_all_bookings()
backend/booking_store.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory user/payment database and booking storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ # Pre-defined cardholders with payment methods (keyed by lowercase name)
9
+ CARDHOLDERS: dict[str, list[dict]] = {
10
+ "john smith": [
11
+ {"card_number": "4111111111111111", "expiry": "12/27", "cvv": "123", "type": "Visa"},
12
+ {"card_number": "5500000000000004", "expiry": "06/28", "cvv": "456", "type": "Mastercard"},
13
+ ],
14
+ "jane doe": [
15
+ {"card_number": "340000000000009", "expiry": "03/27", "cvv": "7890", "type": "Amex"},
16
+ ],
17
+ "bob wilson": [
18
+ {"card_number": "4012888888881881", "expiry": "09/26", "cvv": "321", "type": "Visa"},
19
+ ],
20
+ }
21
+
22
+ GENERIC_ERROR = "Payment failed. Please check your card details and try again."
23
+
24
+ # Confirmed bookings stored in memory
25
+ _bookings: list[dict] = []
26
+
27
+
28
+ def _normalize_expiry(expiry: str) -> str:
29
+ """Normalize expiry to MM/YY format. Accepts '1227' or '12/27'."""
30
+ raw = expiry.replace("/", "").replace("-", "").strip()
31
+ if len(raw) == 4 and raw.isdigit():
32
+ return f"{raw[:2]}/{raw[2:]}"
33
+ return expiry
34
+
35
+
36
+ def validate_payment(cardholder_name: str, card_number: str, expiry: str, cvv: str) -> tuple[bool, str, str | None]:
37
+ """Validate payment against stored cardholder data.
38
+
39
+ Returns (success, error_message, card_type).
40
+ """
41
+ cards = CARDHOLDERS.get(cardholder_name.strip().lower())
42
+ if not cards:
43
+ return False, GENERIC_ERROR, None
44
+
45
+ normalized_expiry = _normalize_expiry(expiry)
46
+
47
+ for card in cards:
48
+ if (
49
+ card["card_number"] == card_number
50
+ and card["expiry"] == normalized_expiry
51
+ and card["cvv"] == cvv
52
+ ):
53
+ return True, "", card["type"]
54
+
55
+ return False, GENERIC_ERROR, None
56
+
57
+
58
+ def create_booking(booking_data: dict) -> dict:
59
+ """Store a confirmed booking and return it with a unique ID."""
60
+ booking = {
61
+ "booking_id": f"BK-{uuid.uuid4().hex[:8].upper()}",
62
+ "status": "confirmed",
63
+ "booked_at": datetime.utcnow().isoformat(),
64
+ **booking_data,
65
+ }
66
+ _bookings.append(booking)
67
+ return booking
68
+
69
+
70
+ def get_all_bookings() -> list[dict]:
71
+ """Return all confirmed bookings."""
72
+ return list(_bookings)
backend/config.py CHANGED
@@ -50,8 +50,8 @@ HIGH_COMPETITION_DISCOUNT = 0.05 # -5% if 4+ carriers
50
 
51
  # Advance booking multipliers (days before departure)
52
  ADVANCE_MULTIPLIERS = [
53
- (3, 1.50), # 0-3 days: +50%
54
- (7, 1.35), # 4-7 days: +35%
55
  (14, 1.20), # 8-14 days: +20%
56
  (21, 1.10), # 15-21 days: +10%
57
  (60, 1.00), # 22-60 days: base
@@ -59,6 +59,12 @@ ADVANCE_MULTIPLIERS = [
59
  (float("inf"), 0.95), # 91+ days: -5%
60
  ]
61
 
 
 
 
 
 
 
62
  # Jitter range (±8%)
63
  JITTER_RANGE = 0.08
64
 
 
50
 
51
  # Advance booking multipliers (days before departure)
52
  ADVANCE_MULTIPLIERS = [
53
+ (3, 1.85), # 0-3 days: +85% (last-minute premium)
54
+ (7, 1.50), # 4-7 days: +50%
55
  (14, 1.20), # 8-14 days: +20%
56
  (21, 1.10), # 15-21 days: +10%
57
  (60, 1.00), # 22-60 days: base
 
59
  (float("inf"), 0.95), # 91+ days: -5%
60
  ]
61
 
62
+ # Short-distance surcharge (flat fee added to very short flights)
63
+ # Waived for legs that are part of a connecting itinerary, but NOT for round trips.
64
+ SHORT_DISTANCE_THRESHOLD_KM = 500
65
+ SHORT_DISTANCE_FEE_MIN = 50
66
+ SHORT_DISTANCE_FEE_MAX = 150
67
+
68
  # Jitter range (±8%)
69
  JITTER_RANGE = 0.08
70
 
backend/flight_generator.py CHANGED
@@ -421,6 +421,7 @@ def _generate_connecting_flights(
421
  num_carriers=len(leg.carriers),
422
  dest_continent=dest_ap.continent,
423
  rng=rng,
 
424
  )
425
  leg_price *= CONNECTING_BASE_DISCOUNT
426
  total_price += leg_price
 
421
  num_carriers=len(leg.carriers),
422
  dest_continent=dest_ap.continent,
423
  rng=rng,
424
+ is_connection=True,
425
  )
426
  leg_price *= CONNECTING_BASE_DISCOUNT
427
  total_price += leg_price
backend/main.py CHANGED
@@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles
9
  from fastapi.responses import FileResponse, JSONResponse
10
  from starlette.middleware.base import BaseHTTPMiddleware
11
 
12
- from .api import airports, auth, calendar, search
13
  from .data_loader import get_route_graph
14
  from .hub_detector import compute_hub_scores
15
 
@@ -52,6 +52,7 @@ app.include_router(auth.router)
52
  app.include_router(airports.router)
53
  app.include_router(search.router)
54
  app.include_router(calendar.router)
 
55
 
56
 
57
  @app.on_event("startup")
 
9
  from fastapi.responses import FileResponse, JSONResponse
10
  from starlette.middleware.base import BaseHTTPMiddleware
11
 
12
+ from .api import airports, auth, booking, calendar, search
13
  from .data_loader import get_route_graph
14
  from .hub_detector import compute_hub_scores
15
 
 
52
  app.include_router(airports.router)
53
  app.include_router(search.router)
54
  app.include_router(calendar.router)
55
+ app.include_router(booking.router)
56
 
57
 
58
  @app.on_event("startup")
backend/models.py CHANGED
@@ -154,3 +154,50 @@ class AutocompleteResult(BaseModel):
154
  country: str
155
  display_name: str
156
  hub_score: float = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  country: str
155
  display_name: str
156
  hub_score: float = 0.0
157
+
158
+
159
+ # --- Booking ---
160
+
161
+ class PassengerInfo(BaseModel):
162
+ first_name: str
163
+ last_name: str
164
+ email: str
165
+ phone: str
166
+
167
+
168
+ class PaymentInfo(BaseModel):
169
+ card_number: str
170
+ expiry_date: str # MM/YY
171
+ cvv: str
172
+ cardholder_name: str
173
+
174
+
175
+ class BookingFlightInfo(BaseModel):
176
+ id: str
177
+ origin: str
178
+ destination: str
179
+ departure: datetime
180
+ arrival: datetime
181
+ total_duration_minutes: int
182
+ stops: int
183
+ price_usd: float
184
+ cabin_class: CabinClass
185
+ segments: list[FlightSegment]
186
+
187
+
188
+ class BookingRequest(BaseModel):
189
+ passenger: PassengerInfo
190
+ payment: PaymentInfo
191
+ outbound_flight: BookingFlightInfo
192
+ return_flight: Optional[BookingFlightInfo] = None
193
+
194
+
195
+ class BookingResponse(BaseModel):
196
+ booking_id: str
197
+ status: str
198
+ booked_at: str
199
+ passenger: PassengerInfo
200
+ payment_summary: dict
201
+ outbound_flight: BookingFlightInfo
202
+ return_flight: Optional[BookingFlightInfo] = None
203
+ total_price: float
backend/price_engine.py CHANGED
@@ -76,7 +76,12 @@ are applied at the itinerary level for connecting flights and round trips.
76
  1.0 ± 0.08 (±8%, drawn from uniform distribution)
77
  Uses SHA-256 seeded RNG so same search → same jitter.
78
 
79
- Minimum price floor: $25 (after all multipliers).
 
 
 
 
 
80
 
81
  --------------------------------------------------------------------------
82
  3. ITINERARY-LEVEL DISCOUNTS
@@ -143,6 +148,9 @@ from .config import (
143
  JITTER_RANGE,
144
  MONOPOLY_ROUTE_BONUS,
145
  SEASON_MULTIPLIERS,
 
 
 
146
  )
147
 
148
 
@@ -155,6 +163,7 @@ def compute_price(
155
  dest_continent: str,
156
  rng: random.Random,
157
  booking_date: date | None = None,
 
158
  ) -> float:
159
  """Compute a single-flight price using the 7-multiplier formula.
160
 
@@ -171,6 +180,8 @@ def compute_price(
171
  dest_continent: Destination airport continent code (for EU summer bonus).
172
  rng: Seeded Random instance (for deterministic jitter).
173
  booking_date: Date of booking (defaults to today; affects advance multiplier).
 
 
174
 
175
  Returns:
176
  Price in USD, rounded to nearest dollar, minimum $25.
@@ -223,6 +234,11 @@ def compute_price(
223
  price = (base * class_mult * day_mult * time_mult
224
  * season_mult * demand_mult * advance_mult * jitter)
225
 
 
 
 
 
 
226
  return max(25.0, round(price, 0))
227
 
228
 
 
76
  1.0 ± 0.08 (±8%, drawn from uniform distribution)
77
  Uses SHA-256 seeded RNG so same search → same jitter.
78
 
79
+ Minimum price floor: $25 (after all multipliers and surcharges).
80
+
81
+ 2h. Short-distance surcharge — flat fee for very short flights
82
+ Routes ≤ 500 km get +$50–$150 (random, deterministic per flight).
83
+ Waived when the leg is part of a connecting itinerary.
84
+ Still applies to round-trip flights (each direction independently).
85
 
86
  --------------------------------------------------------------------------
87
  3. ITINERARY-LEVEL DISCOUNTS
 
148
  JITTER_RANGE,
149
  MONOPOLY_ROUTE_BONUS,
150
  SEASON_MULTIPLIERS,
151
+ SHORT_DISTANCE_FEE_MAX,
152
+ SHORT_DISTANCE_FEE_MIN,
153
+ SHORT_DISTANCE_THRESHOLD_KM,
154
  )
155
 
156
 
 
163
  dest_continent: str,
164
  rng: random.Random,
165
  booking_date: date | None = None,
166
+ is_connection: bool = False,
167
  ) -> float:
168
  """Compute a single-flight price using the 7-multiplier formula.
169
 
 
180
  dest_continent: Destination airport continent code (for EU summer bonus).
181
  rng: Seeded Random instance (for deterministic jitter).
182
  booking_date: Date of booking (defaults to today; affects advance multiplier).
183
+ is_connection: True when this leg is part of a connecting itinerary
184
+ (suppresses the short-distance surcharge).
185
 
186
  Returns:
187
  Price in USD, rounded to nearest dollar, minimum $25.
 
234
  price = (base * class_mult * day_mult * time_mult
235
  * season_mult * demand_mult * advance_mult * jitter)
236
 
237
+ # Short-distance surcharge: random $50–$150 fee on very short flights.
238
+ # Waived when the leg is part of a connecting itinerary.
239
+ if distance_km <= SHORT_DISTANCE_THRESHOLD_KM and not is_connection:
240
+ price += rng.randint(SHORT_DISTANCE_FEE_MIN, SHORT_DISTANCE_FEE_MAX)
241
+
242
  return max(25.0, round(price, 0))
243
 
244
 
frontend/src/App.tsx CHANGED
@@ -4,6 +4,8 @@ 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
 
8
  export default function App() {
9
  const [authenticated, setAuthenticated] = useState<boolean | null>(null);
@@ -29,6 +31,8 @@ export default function App() {
29
  <Routes>
30
  <Route path="/" element={<SearchPage />} />
31
  <Route path="/results" element={<ResultsPage />} />
 
 
32
  </Routes>
33
  </BrowserRouter>
34
  );
 
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
 
10
  export default function App() {
11
  const [authenticated, setAuthenticated] = useState<boolean | null>(null);
 
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>
37
  </BrowserRouter>
38
  );
frontend/src/api/client.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AutocompleteResult, CalendarResponse, SearchRequest, SearchResponse } from './types';
2
 
3
  const BASE_URL = '/api';
4
 
@@ -26,6 +26,19 @@ export async function searchFlights(req: SearchRequest): Promise<SearchResponse>
26
  });
27
  }
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  export async function getCalendar(
30
  origin: string,
31
  destination: string,
 
1
+ import type { AutocompleteResult, BookingRequest, BookingResponse, CalendarResponse, SearchRequest, SearchResponse } from './types';
2
 
3
  const BASE_URL = '/api';
4
 
 
26
  });
27
  }
28
 
29
+ export async function bookFlight(req: BookingRequest): Promise<BookingResponse> {
30
+ const res = await fetch(`${BASE_URL}/booking`, {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify(req),
34
+ });
35
+ if (!res.ok) {
36
+ const data = await res.json().catch(() => null);
37
+ throw new Error(data?.detail || `Booking failed (${res.status})`);
38
+ }
39
+ return res.json();
40
+ }
41
+
42
  export async function getCalendar(
43
  origin: string,
44
  destination: string,
frontend/src/api/types.ts CHANGED
@@ -99,3 +99,41 @@ export interface CalendarResponse {
99
  month: number;
100
  days: CalendarDay[];
101
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  month: number;
100
  days: CalendarDay[];
101
  }
102
+
103
+ // --- Booking ---
104
+
105
+ export interface PassengerInfo {
106
+ first_name: string;
107
+ last_name: string;
108
+ email: string;
109
+ phone: string;
110
+ }
111
+
112
+ export interface PaymentInfo {
113
+ card_number: string;
114
+ expiry_date: string; // MM/YY
115
+ cvv: string;
116
+ cardholder_name: string;
117
+ }
118
+
119
+ export interface BookingRequest {
120
+ passenger: PassengerInfo;
121
+ payment: PaymentInfo;
122
+ outbound_flight: FlightOffer;
123
+ return_flight?: FlightOffer | null;
124
+ }
125
+
126
+ export interface BookingResponse {
127
+ booking_id: string;
128
+ status: string;
129
+ booked_at: string;
130
+ passenger: PassengerInfo;
131
+ payment_summary: {
132
+ card_type: string;
133
+ masked_card: string;
134
+ cardholder_name: string;
135
+ };
136
+ outbound_flight: FlightOffer;
137
+ return_flight?: FlightOffer | null;
138
+ total_price: number;
139
+ }
frontend/src/components/results/FlightCard.tsx CHANGED
@@ -7,6 +7,7 @@ interface Props {
7
  roundTripPrice?: number | null;
8
  priceLabel?: string;
9
  onSelect?: (flight: FlightOffer) => void;
 
10
  discountApplied?: boolean;
11
  }
12
 
@@ -14,7 +15,7 @@ function layoverMinutes(seg1Arrival: string, seg2Departure: string): number {
14
  return Math.round((new Date(seg2Departure).getTime() - new Date(seg1Arrival).getTime()) / 60000);
15
  }
16
 
17
- export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelect, discountApplied }: Props) {
18
  const [expanded, setExpanded] = useState(false);
19
  const firstSeg = flight.segments[0];
20
 
@@ -89,6 +90,15 @@ export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelec
89
  Select flight
90
  </button>
91
  )}
 
 
 
 
 
 
 
 
 
92
  <div className="text-right min-w-[80px]" data-testid="price">
93
  {roundTripPrice != null ? (
94
  <>
 
7
  roundTripPrice?: number | null;
8
  priceLabel?: string;
9
  onSelect?: (flight: FlightOffer) => void;
10
+ onBook?: (flight: FlightOffer) => void;
11
  discountApplied?: boolean;
12
  }
13
 
 
15
  return Math.round((new Date(seg2Departure).getTime() - new Date(seg1Arrival).getTime()) / 60000);
16
  }
17
 
18
+ export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelect, onBook, discountApplied }: Props) {
19
  const [expanded, setExpanded] = useState(false);
20
  const firstSeg = flight.segments[0];
21
 
 
90
  Select flight
91
  </button>
92
  )}
93
+ {onBook && expanded && (
94
+ <button
95
+ onClick={(e) => { e.stopPropagation(); onBook(flight); }}
96
+ className="rounded-full bg-[#1a73e8] px-4 py-1.5 text-xs font-medium text-white hover:bg-[#1557b0] cursor-pointer whitespace-nowrap"
97
+ data-testid="book-flight-btn"
98
+ >
99
+ Book
100
+ </button>
101
+ )}
102
  <div className="text-right min-w-[80px]" data-testid="price">
103
  {roundTripPrice != null ? (
104
  <>
frontend/src/pages/BookingPage.tsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { useLocation, useNavigate } from 'react-router-dom';
3
+ 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 }) {
13
+ const firstSeg = flight.segments[0];
14
+ const lastSeg = flight.segments[flight.segments.length - 1];
15
+ return (
16
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
17
+ <div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">{label}</div>
18
+ <div className="flex items-center justify-between">
19
+ <div>
20
+ <div className="text-sm font-medium text-gray-900">
21
+ {formatTime(flight.departure)} &ndash; {formatTime(flight.arrival)}
22
+ </div>
23
+ <div className="text-xs text-gray-500 mt-0.5">
24
+ {firstSeg.origin_city} ({flight.origin}) &rarr; {lastSeg.destination_city} ({flight.destination})
25
+ </div>
26
+ <div className="text-xs text-gray-400 mt-0.5">
27
+ {formatDate(flight.departure)} &middot; {formatDuration(flight.total_duration_minutes)} &middot; {formatStops(flight.stops)}
28
+ </div>
29
+ <div className="text-xs text-gray-400 mt-0.5">
30
+ {firstSeg.airline_name} {firstSeg.flight_number}
31
+ </div>
32
+ </div>
33
+ <div className="text-right">
34
+ <div className="text-lg font-semibold text-gray-900">{formatPrice(flight.price_usd)}</div>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ export default function BookingPage() {
42
+ const location = useLocation();
43
+ const navigate = useNavigate();
44
+ const state = location.state as LocationState | null;
45
+
46
+ const [firstName, setFirstName] = useState('');
47
+ const [lastName, setLastName] = useState('');
48
+ const [email, setEmail] = useState('');
49
+ const [phone, setPhone] = useState('');
50
+ const [cardNumber, setCardNumber] = useState('');
51
+ const [expiry, setExpiry] = useState('');
52
+ const [cvv, setCvv] = useState('');
53
+ const [cardholderName, setCardholderName] = useState('');
54
+ const [error, setError] = useState('');
55
+ const [submitting, setSubmitting] = useState(false);
56
+
57
+ if (!state?.outboundFlight) {
58
+ return (
59
+ <div className="mx-auto max-w-3xl px-4 py-12 text-center">
60
+ <h1 className="text-xl font-semibold text-gray-900 mb-2">No flight selected</h1>
61
+ <p className="text-gray-500 mb-4">Please search for flights and select one to book.</p>
62
+ <button onClick={() => navigate('/')} className="text-[#1a73e8] hover:underline cursor-pointer">
63
+ Back to search
64
+ </button>
65
+ </div>
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();
74
+ setError('');
75
+ setSubmitting(true);
76
+
77
+ try {
78
+ const response = await bookFlight({
79
+ passenger: { first_name: firstName, last_name: lastName, email, phone },
80
+ payment: { card_number: cardNumber, expiry_date: expiry, cvv, cardholder_name: cardholderName },
81
+ outbound_flight: outboundFlight,
82
+ return_flight: returnFlight || null,
83
+ });
84
+ navigate('/confirmation', { state: { booking: response } });
85
+ } catch (err: unknown) {
86
+ setError(err instanceof Error ? err.message : 'Booking failed');
87
+ } finally {
88
+ setSubmitting(false);
89
+ }
90
+ }
91
+
92
+ return (
93
+ <div className="mx-auto max-w-3xl px-4 py-8">
94
+ <h1 className="text-2xl font-semibold text-gray-900 mb-6">Complete your booking</h1>
95
+
96
+ {/* Flight summary */}
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>
103
+ </div>
104
+
105
+ {/* Error banner */}
106
+ {error && (
107
+ <div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-700" data-testid="booking-error">
108
+ {error}
109
+ </div>
110
+ )}
111
+
112
+ <form onSubmit={handleSubmit} className="space-y-8">
113
+ {/* Passenger info */}
114
+ <section>
115
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Passenger information</h2>
116
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
117
+ <div>
118
+ <label className="block text-sm font-medium text-gray-700 mb-1">First name</label>
119
+ <input
120
+ type="text" required value={firstName} onChange={e => setFirstName(e.target.value)}
121
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8]"
122
+ />
123
+ </div>
124
+ <div>
125
+ <label className="block text-sm font-medium text-gray-700 mb-1">Last name</label>
126
+ <input
127
+ type="text" required value={lastName} onChange={e => setLastName(e.target.value)}
128
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8]"
129
+ />
130
+ </div>
131
+ <div>
132
+ <label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
133
+ <input
134
+ type="email" required value={email} onChange={e => setEmail(e.target.value)}
135
+ placeholder="john@example.com"
136
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8]"
137
+ />
138
+ </div>
139
+ <div>
140
+ <label className="block text-sm font-medium text-gray-700 mb-1">Phone</label>
141
+ <input
142
+ type="tel" required value={phone} onChange={e => setPhone(e.target.value)}
143
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8]"
144
+ />
145
+ </div>
146
+ </div>
147
+ </section>
148
+
149
+ {/* Payment info */}
150
+ <section>
151
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Payment information</h2>
152
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
153
+ <div className="sm:col-span-2">
154
+ <label className="block text-sm font-medium text-gray-700 mb-1">Card number</label>
155
+ <input
156
+ type="text" required value={cardNumber} onChange={e => setCardNumber(e.target.value)}
157
+ placeholder="4111111111111111"
158
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8]"
159
+ />
160
+ </div>
161
+ <div>
162
+ <label className="block text-sm font-medium text-gray-700 mb-1">Expiry date</label>
163
+ <input
164
+ type="text" required value={expiry} onChange={e => setExpiry(e.target.value)}
165
+ placeholder="MM/YY"
166
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8]"
167
+ />
168
+ </div>
169
+ <div>
170
+ <label className="block text-sm font-medium text-gray-700 mb-1">CVV</label>
171
+ <input
172
+ type="text" required value={cvv} onChange={e => setCvv(e.target.value)}
173
+ placeholder="123"
174
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8]"
175
+ />
176
+ </div>
177
+ <div className="sm:col-span-2">
178
+ <label className="block text-sm font-medium text-gray-700 mb-1">Cardholder name</label>
179
+ <input
180
+ type="text" required value={cardholderName} onChange={e => setCardholderName(e.target.value)}
181
+ placeholder="John Smith"
182
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#1a73e8] focus:outline-none focus:ring-1 focus:ring-[#1a73e8]"
183
+ />
184
+ </div>
185
+ </div>
186
+ </section>
187
+
188
+ <button
189
+ type="submit"
190
+ disabled={submitting}
191
+ className="w-full rounded-lg bg-[#1a73e8] py-3 text-sm font-medium text-white hover:bg-[#1557b0] disabled:opacity-50 cursor-pointer"
192
+ >
193
+ {submitting ? 'Processing...' : `Pay ${formatPrice(totalPrice)}`}
194
+ </button>
195
+ </form>
196
+ </div>
197
+ );
198
+ }
frontend/src/pages/ConfirmationPage.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useLocation, useNavigate } from 'react-router-dom';
2
+ import type { BookingResponse } from '../api/types';
3
+ import { formatDate, formatDuration, formatPrice, formatStops, formatTime } from '../utils/format';
4
+ import type { FlightOffer } from '../api/types';
5
+
6
+ interface LocationState {
7
+ booking: BookingResponse;
8
+ }
9
+
10
+ function FlightDetail({ label, flight }: { label: string; flight: FlightOffer }) {
11
+ const firstSeg = flight.segments[0];
12
+ const lastSeg = flight.segments[flight.segments.length - 1];
13
+ return (
14
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
15
+ <div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">{label}</div>
16
+ <div className="flex items-center justify-between">
17
+ <div>
18
+ <div className="text-sm font-medium text-gray-900">
19
+ {formatTime(flight.departure)} &ndash; {formatTime(flight.arrival)}
20
+ </div>
21
+ <div className="text-xs text-gray-500 mt-0.5">
22
+ {firstSeg.origin_city} ({flight.origin}) &rarr; {lastSeg.destination_city} ({flight.destination})
23
+ </div>
24
+ <div className="text-xs text-gray-400 mt-0.5">
25
+ {formatDate(flight.departure)} &middot; {formatDuration(flight.total_duration_minutes)} &middot; {formatStops(flight.stops)}
26
+ </div>
27
+ <div className="text-xs text-gray-400 mt-0.5">
28
+ {firstSeg.airline_name} {firstSeg.flight_number}
29
+ </div>
30
+ </div>
31
+ <div className="text-right">
32
+ <div className="text-lg font-semibold text-gray-900">{formatPrice(flight.price_usd)}</div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export default function ConfirmationPage() {
40
+ const location = useLocation();
41
+ const navigate = useNavigate();
42
+ const state = location.state as LocationState | null;
43
+
44
+ if (!state?.booking) {
45
+ return (
46
+ <div className="mx-auto max-w-3xl px-4 py-12 text-center">
47
+ <h1 className="text-xl font-semibold text-gray-900 mb-2">No booking found</h1>
48
+ <p className="text-gray-500 mb-4">Please search for flights and complete a booking.</p>
49
+ <button onClick={() => navigate('/')} className="text-[#1a73e8] hover:underline cursor-pointer">
50
+ Back to search
51
+ </button>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ const { booking } = state;
57
+
58
+ return (
59
+ <div className="mx-auto max-w-3xl px-4 py-8">
60
+ {/* Success banner */}
61
+ <div className="mb-8 rounded-lg bg-green-50 border border-green-200 p-6 text-center">
62
+ <svg className="mx-auto h-12 w-12 text-green-500 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
63
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
64
+ </svg>
65
+ <h1 className="text-2xl font-semibold text-green-800">Booking confirmed!</h1>
66
+ <p className="text-green-700 mt-1">
67
+ Booking ID: <span className="font-mono font-semibold">{booking.booking_id}</span>
68
+ </p>
69
+ </div>
70
+
71
+ {/* Flight details */}
72
+ <section className="mb-8">
73
+ <h2 className="text-lg font-medium text-gray-900 mb-3">Flight details</h2>
74
+ <div className="space-y-3">
75
+ <FlightDetail label="Outbound flight" flight={booking.outbound_flight} />
76
+ {booking.return_flight && (
77
+ <FlightDetail label="Return flight" flight={booking.return_flight} />
78
+ )}
79
+ </div>
80
+ </section>
81
+
82
+ {/* Passenger info */}
83
+ <section className="mb-8">
84
+ <h2 className="text-lg font-medium text-gray-900 mb-3">Passenger</h2>
85
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 space-y-1">
86
+ <div>{booking.passenger.first_name} {booking.passenger.last_name}</div>
87
+ <div className="text-gray-500">{booking.passenger.email}</div>
88
+ <div className="text-gray-500">{booking.passenger.phone}</div>
89
+ </div>
90
+ </section>
91
+
92
+ {/* Payment summary */}
93
+ <section className="mb-8">
94
+ <h2 className="text-lg font-medium text-gray-900 mb-3">Payment</h2>
95
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 space-y-1">
96
+ <div>{booking.payment_summary.card_type} {booking.payment_summary.masked_card}</div>
97
+ <div className="text-gray-500">{booking.payment_summary.cardholder_name}</div>
98
+ <div className="text-lg font-semibold text-gray-900 mt-2">
99
+ Total charged: {formatPrice(booking.total_price)}
100
+ </div>
101
+ </div>
102
+ </section>
103
+
104
+ <button
105
+ onClick={() => navigate('/')}
106
+ className="w-full rounded-lg bg-[#1a73e8] py-3 text-sm font-medium text-white hover:bg-[#1557b0] cursor-pointer"
107
+ >
108
+ Back to search
109
+ </button>
110
+ </div>
111
+ );
112
+ }
frontend/src/pages/ResultsPage.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useCallback, useEffect, useMemo, useState } from 'react';
2
- import { useSearchParams } from 'react-router-dom';
3
  import type { CabinClass, Filters, FlightOffer, Passengers, SortBy, TripType } from '../api/types';
4
  import { getLocalMinuteOfDay } from '../utils/format';
5
  import { minRoundTripPrice, roundTripTotal, sharesAirline } from '../utils/pricing';
@@ -20,6 +20,7 @@ const EMPTY_FILTERS: Filters = {
20
 
21
  export default function ResultsPage() {
22
  const [searchParams, setSearchParams] = useSearchParams();
 
23
  const { outboundFlights, returnFlights, sameAirlineDiscount, loading, error, searched, search } = useFlightSearch();
24
  const [sortBy, setSortBy] = useState<SortBy>('best');
25
  const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
@@ -175,6 +176,14 @@ export default function ResultsPage() {
175
  setSortBy('best');
176
  }
177
 
 
 
 
 
 
 
 
 
178
  return (
179
  <div className="min-h-screen bg-gray-50" data-testid="results-page">
180
  {/* Collapsed search form */}
@@ -276,6 +285,7 @@ export default function ResultsPage() {
276
  : isRoundTrip ? outboundMinPrices.get(flight.id) : undefined}
277
  priceLabel={showingReturn ? 'round trip' : isRoundTrip ? 'from, round trip' : undefined}
278
  onSelect={isRoundTrip && !showingReturn ? handleSelectOutbound : undefined}
 
279
  discountApplied={showingReturn && selectedOutbound ? sharesAirline(flight, selectedOutbound) : false}
280
  />
281
  ))}
@@ -305,6 +315,7 @@ export default function ResultsPage() {
305
  : isRoundTrip ? outboundMinPrices.get(flight.id) : undefined}
306
  priceLabel={showingReturn ? 'round trip' : isRoundTrip ? 'from, round trip' : undefined}
307
  onSelect={isRoundTrip && !showingReturn ? handleSelectOutbound : undefined}
 
308
  discountApplied={showingReturn && selectedOutbound ? sharesAirline(flight, selectedOutbound) : false}
309
  />
310
  ))}
 
1
  import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { useNavigate, useSearchParams } from 'react-router-dom';
3
  import type { CabinClass, Filters, FlightOffer, Passengers, SortBy, TripType } from '../api/types';
4
  import { getLocalMinuteOfDay } from '../utils/format';
5
  import { minRoundTripPrice, roundTripTotal, sharesAirline } from '../utils/pricing';
 
20
 
21
  export default function ResultsPage() {
22
  const [searchParams, setSearchParams] = useSearchParams();
23
+ const navigate = useNavigate();
24
  const { outboundFlights, returnFlights, sameAirlineDiscount, loading, error, searched, search } = useFlightSearch();
25
  const [sortBy, setSortBy] = useState<SortBy>('best');
26
  const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
 
176
  setSortBy('best');
177
  }
178
 
179
+ function handleBookOneWay(flight: FlightOffer) {
180
+ navigate('/booking', { state: { outboundFlight: flight } });
181
+ }
182
+
183
+ function handleBookReturn(returnFlight: FlightOffer) {
184
+ navigate('/booking', { state: { outboundFlight: selectedOutbound, returnFlight } });
185
+ }
186
+
187
  return (
188
  <div className="min-h-screen bg-gray-50" data-testid="results-page">
189
  {/* Collapsed search form */}
 
285
  : isRoundTrip ? outboundMinPrices.get(flight.id) : undefined}
286
  priceLabel={showingReturn ? 'round trip' : isRoundTrip ? 'from, round trip' : undefined}
287
  onSelect={isRoundTrip && !showingReturn ? handleSelectOutbound : undefined}
288
+ onBook={!isRoundTrip ? handleBookOneWay : showingReturn ? handleBookReturn : undefined}
289
  discountApplied={showingReturn && selectedOutbound ? sharesAirline(flight, selectedOutbound) : false}
290
  />
291
  ))}
 
315
  : isRoundTrip ? outboundMinPrices.get(flight.id) : undefined}
316
  priceLabel={showingReturn ? 'round trip' : isRoundTrip ? 'from, round trip' : undefined}
317
  onSelect={isRoundTrip && !showingReturn ? handleSelectOutbound : undefined}
318
+ onBook={!isRoundTrip ? handleBookOneWay : showingReturn ? handleBookReturn : undefined}
319
  discountApplied={showingReturn && selectedOutbound ? sharesAirline(flight, selectedOutbound) : false}
320
  />
321
  ))}