Spaces:
Running
Running
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 +1 -0
- backend/api/booking.py +47 -0
- backend/booking_store.py +72 -0
- backend/config.py +8 -2
- backend/flight_generator.py +1 -0
- backend/main.py +2 -1
- backend/models.py +47 -0
- backend/price_engine.py +17 -1
- frontend/src/App.tsx +4 -0
- frontend/src/api/client.ts +14 -1
- frontend/src/api/types.ts +38 -0
- frontend/src/components/results/FlightCard.tsx +11 -1
- frontend/src/pages/BookingPage.tsx +198 -0
- frontend/src/pages/ConfirmationPage.tsx +112 -0
- frontend/src/pages/ResultsPage.tsx +12 -1
.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.
|
| 54 |
-
(7, 1.
|
| 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)} – {formatTime(flight.arrival)}
|
| 22 |
+
</div>
|
| 23 |
+
<div className="text-xs text-gray-500 mt-0.5">
|
| 24 |
+
{firstSeg.origin_city} ({flight.origin}) → {lastSeg.destination_city} ({flight.destination})
|
| 25 |
+
</div>
|
| 26 |
+
<div className="text-xs text-gray-400 mt-0.5">
|
| 27 |
+
{formatDate(flight.departure)} · {formatDuration(flight.total_duration_minutes)} · {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)} – {formatTime(flight.arrival)}
|
| 20 |
+
</div>
|
| 21 |
+
<div className="text-xs text-gray-500 mt-0.5">
|
| 22 |
+
{firstSeg.origin_city} ({flight.origin}) → {lastSeg.destination_city} ({flight.destination})
|
| 23 |
+
</div>
|
| 24 |
+
<div className="text-xs text-gray-400 mt-0.5">
|
| 25 |
+
{formatDate(flight.departure)} · {formatDuration(flight.total_duration_minutes)} · {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 |
))}
|