fyliu Claude Opus 4.6 commited on
Commit
c090f53
·
1 Parent(s): 9d442c9

Add Google Flights-style step-based round-trip booking flow

Browse files

Replace outbound/return toggle with a two-step selection flow: users
first pick a departing flight (showing "from $X round trip" preview
prices), then see return flights priced as round-trip totals with
same-airline discounts applied per-selection rather than blanket.

Backend: move same-airline discount (0.92x) from server-side blanket
application to client-side per-selection; return discount rate in API.
Frontend: add step breadcrumb, SelectedFlightBanner, pricing utilities,
and round-trip-aware sorting. One-way searches unaffected.

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

backend/api/search.py CHANGED
@@ -171,19 +171,9 @@ async def search_flights(req: SearchRequest):
171
  )
172
  return_flights.extend(flights)
173
 
174
- # Round-trip same-airline discount: return flights that share at least
175
- # one carrier with any outbound flight get a price reduction, simulating
176
- # bundled round-trip fares. (See config.py for the multiplier value.)
177
- if outbound_flights:
178
- outbound_airlines = set()
179
- for fl in outbound_flights:
180
- for seg in fl.segments:
181
- outbound_airlines.add(seg.airline_code)
182
-
183
- for fl in return_flights:
184
- flight_airlines = {seg.airline_code for seg in fl.segments}
185
- if flight_airlines & outbound_airlines:
186
- fl.price_usd = round(fl.price_usd * ROUND_TRIP_SAME_AIRLINE_DISCOUNT, 0)
187
 
188
  return_flights = _apply_filters(return_flights, req)
189
  return_flights = _sort_flights(return_flights, req.sort_by)
@@ -198,4 +188,5 @@ async def search_flights(req: SearchRequest):
198
  search_id=search_id,
199
  origin=origin,
200
  destination=destination,
 
201
  )
 
171
  )
172
  return_flights.extend(flights)
173
 
174
+ # Round-trip same-airline discount is now applied client-side based on
175
+ # the user's selected outbound flight (not blanket across all outbound
176
+ # airlines). The discount rate is returned in same_airline_discount.
 
 
 
 
 
 
 
 
 
 
177
 
178
  return_flights = _apply_filters(return_flights, req)
179
  return_flights = _sort_flights(return_flights, req.sort_by)
 
188
  search_id=search_id,
189
  origin=origin,
190
  destination=destination,
191
+ same_airline_discount=ROUND_TRIP_SAME_AIRLINE_DISCOUNT if return_flights else 1.0,
192
  )
backend/models.py CHANGED
@@ -127,6 +127,7 @@ class SearchResponse(BaseModel):
127
  search_id: str
128
  origin: str
129
  destination: str
 
130
 
131
 
132
  # --- Calendar ---
 
127
  search_id: str
128
  origin: str
129
  destination: str
130
+ same_airline_discount: float = 1.0
131
 
132
 
133
  # --- Calendar ---
frontend/src/api/types.ts CHANGED
@@ -84,6 +84,7 @@ export interface SearchResponse {
84
  search_id: string;
85
  origin: string;
86
  destination: string;
 
87
  }
88
 
89
  export interface CalendarDay {
 
84
  search_id: string;
85
  origin: string;
86
  destination: string;
87
+ same_airline_discount: number;
88
  }
89
 
90
  export interface CalendarDay {
frontend/src/components/results/FlightCard.tsx CHANGED
@@ -4,13 +4,17 @@ import { daysBetween, formatDuration, formatPrice, formatStops, formatTime } fro
4
 
5
  interface Props {
6
  flight: FlightOffer;
 
 
 
 
7
  }
8
 
9
  function layoverMinutes(seg1Arrival: string, seg2Departure: string): number {
10
  return Math.round((new Date(seg2Departure).getTime() - new Date(seg1Arrival).getTime()) / 60000);
11
  }
12
 
13
- export default function FlightCard({ flight }: Props) {
14
  const [expanded, setExpanded] = useState(false);
15
  const firstSeg = flight.segments[0];
16
 
@@ -76,8 +80,20 @@ export default function FlightCard({ flight }: Props) {
76
 
77
  {/* Price column */}
78
  <div className="text-right pl-3 min-w-[80px]" data-testid="price">
79
- <div className="text-[15px] font-medium text-gray-900">{formatPrice(flight.price_usd)}</div>
80
- <div className="text-[11px] text-gray-500">{flight.cabin_class.replace('_', ' ')}</div>
 
 
 
 
 
 
 
 
 
 
 
 
81
  </div>
82
 
83
  {/* Chevron */}
@@ -89,6 +105,19 @@ export default function FlightCard({ flight }: Props) {
89
  </svg>
90
  </div>
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  {/* Expanded detail view */}
93
  {expanded && (
94
  <div className="border-t border-gray-100 px-4 pt-3 pb-4" data-testid="segments-detail">
 
4
 
5
  interface Props {
6
  flight: FlightOffer;
7
+ roundTripPrice?: number | null;
8
+ priceLabel?: string;
9
+ onSelect?: (flight: FlightOffer) => void;
10
+ discountApplied?: boolean;
11
  }
12
 
13
  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
 
 
80
 
81
  {/* Price column */}
82
  <div className="text-right pl-3 min-w-[80px]" data-testid="price">
83
+ {roundTripPrice != null ? (
84
+ <>
85
+ <div className="text-[15px] font-medium text-gray-900">{formatPrice(roundTripPrice)}</div>
86
+ <div className="text-[11px] text-gray-500">{priceLabel || 'round trip'}</div>
87
+ </>
88
+ ) : (
89
+ <>
90
+ <div className="text-[15px] font-medium text-gray-900">{formatPrice(flight.price_usd)}</div>
91
+ <div className="text-[11px] text-gray-500">{flight.cabin_class.replace('_', ' ')}</div>
92
+ </>
93
+ )}
94
+ {discountApplied && (
95
+ <div className="text-[10px] text-green-600 mt-0.5">Airline discount</div>
96
+ )}
97
  </div>
98
 
99
  {/* Chevron */}
 
105
  </svg>
106
  </div>
107
 
108
+ {/* Select flight button */}
109
+ {onSelect && (
110
+ <div className="px-4 pb-3 -mt-1">
111
+ <button
112
+ onClick={(e) => { e.stopPropagation(); onSelect(flight); }}
113
+ className="w-full rounded-full bg-[#1a73e8] px-4 py-2 text-sm font-medium text-white hover:bg-[#1557b0] cursor-pointer"
114
+ data-testid="select-flight-btn"
115
+ >
116
+ Select flight
117
+ </button>
118
+ </div>
119
+ )}
120
+
121
  {/* Expanded detail view */}
122
  {expanded && (
123
  <div className="border-t border-gray-100 px-4 pt-3 pb-4" data-testid="segments-detail">
frontend/src/components/results/SelectedFlightBanner.tsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FlightOffer } from '../../api/types';
2
+ import { formatDuration, formatPrice, formatStops, formatTime } from '../../utils/format';
3
+
4
+ interface Props {
5
+ flight: FlightOffer;
6
+ onChangeClick: () => void;
7
+ }
8
+
9
+ export default function SelectedFlightBanner({ flight, onChangeClick }: Props) {
10
+ const airlineNames = [...new Set(flight.segments.map(s => s.airline_name))];
11
+
12
+ return (
13
+ <div
14
+ className="mb-4 flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 px-4 py-3"
15
+ data-testid="selected-flight-banner"
16
+ >
17
+ <div className="flex items-center gap-4 min-w-0">
18
+ {/* Airline badge */}
19
+ <div className="flex h-8 w-8 items-center justify-center rounded bg-white text-[11px] font-bold text-gray-600 flex-shrink-0">
20
+ {flight.segments[0].airline_code}
21
+ </div>
22
+
23
+ {/* Flight summary */}
24
+ <div className="min-w-0">
25
+ <div className="flex items-center gap-2 text-sm font-medium text-gray-900">
26
+ <span>{formatTime(flight.departure)}</span>
27
+ <span className="text-gray-400">&ndash;</span>
28
+ <span>{formatTime(flight.arrival)}</span>
29
+ </div>
30
+ <div className="text-xs text-gray-500 truncate">
31
+ {airlineNames.join(', ')} · {formatDuration(flight.total_duration_minutes)} · {formatStops(flight.stops)}
32
+ </div>
33
+ </div>
34
+
35
+ {/* Price */}
36
+ <div className="text-sm font-medium text-gray-900 flex-shrink-0 ml-2">
37
+ {formatPrice(flight.price_usd)}
38
+ </div>
39
+ </div>
40
+
41
+ {/* Change button */}
42
+ <button
43
+ onClick={onChangeClick}
44
+ className="ml-4 flex-shrink-0 rounded-full border border-[#1a73e8] px-4 py-1.5 text-sm font-medium text-[#1a73e8] hover:bg-blue-50 cursor-pointer"
45
+ data-testid="change-outbound-btn"
46
+ >
47
+ Change
48
+ </button>
49
+ </div>
50
+ );
51
+ }
frontend/src/hooks/useFlightSearch.ts CHANGED
@@ -5,6 +5,7 @@ import type { CabinClass, Filters, FlightOffer, Passengers, SearchRequest, SortB
5
  interface SearchState {
6
  outboundFlights: FlightOffer[];
7
  returnFlights: FlightOffer[];
 
8
  loading: boolean;
9
  error: string | null;
10
  searched: boolean;
@@ -14,6 +15,7 @@ export function useFlightSearch() {
14
  const [state, setState] = useState<SearchState>({
15
  outboundFlights: [],
16
  returnFlights: [],
 
17
  loading: false,
18
  error: null,
19
  searched: false,
@@ -51,6 +53,7 @@ export function useFlightSearch() {
51
  setState({
52
  outboundFlights: res.outbound_flights,
53
  returnFlights: res.return_flights,
 
54
  loading: false,
55
  error: null,
56
  searched: true,
@@ -59,6 +62,7 @@ export function useFlightSearch() {
59
  setState({
60
  outboundFlights: [],
61
  returnFlights: [],
 
62
  loading: false,
63
  error: err instanceof Error ? err.message : 'Search failed',
64
  searched: true,
 
5
  interface SearchState {
6
  outboundFlights: FlightOffer[];
7
  returnFlights: FlightOffer[];
8
+ sameAirlineDiscount: number;
9
  loading: boolean;
10
  error: string | null;
11
  searched: boolean;
 
15
  const [state, setState] = useState<SearchState>({
16
  outboundFlights: [],
17
  returnFlights: [],
18
+ sameAirlineDiscount: 1.0,
19
  loading: false,
20
  error: null,
21
  searched: false,
 
53
  setState({
54
  outboundFlights: res.outbound_flights,
55
  returnFlights: res.return_flights,
56
+ sameAirlineDiscount: res.same_airline_discount ?? 1.0,
57
  loading: false,
58
  error: null,
59
  searched: true,
 
62
  setState({
63
  outboundFlights: [],
64
  returnFlights: [],
65
+ sameAirlineDiscount: 1.0,
66
  loading: false,
67
  error: err instanceof Error ? err.message : 'Search failed',
68
  searched: true,
frontend/src/pages/ResultsPage.tsx CHANGED
@@ -1,12 +1,14 @@
1
  import { useCallback, useEffect, useMemo, useState } from 'react';
2
  import { useSearchParams } from 'react-router-dom';
3
- import type { CabinClass, Filters, Passengers, SortBy, TripType } from '../api/types';
4
  import { getLocalMinuteOfDay } from '../utils/format';
 
5
  import SearchForm from '../components/search/SearchForm';
6
  import type { SearchFormData } from '../components/search/SearchForm';
7
  import FlightCard from '../components/results/FlightCard';
8
  import FilterPanel from '../components/results/FilterPanel';
9
  import NoResults from '../components/results/NoResults';
 
10
  import SortBar from '../components/results/SortBar';
11
  import Loading from '../components/shared/Loading';
12
  import { useFlightSearch } from '../hooks/useFlightSearch';
@@ -18,10 +20,11 @@ const EMPTY_FILTERS: Filters = {
18
 
19
  export default function ResultsPage() {
20
  const [searchParams, setSearchParams] = useSearchParams();
21
- const { outboundFlights, returnFlights, loading, error, searched, search } = useFlightSearch();
22
  const [sortBy, setSortBy] = useState<SortBy>('best');
23
  const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
24
- const [showReturn, setShowReturn] = useState(false);
 
25
 
26
  // Parse URL params
27
  const origin = searchParams.get('origin') || '';
@@ -38,6 +41,9 @@ export default function ResultsPage() {
38
  const originDisplay = searchParams.get('originDisplay') || origin;
39
  const destinationDisplay = searchParams.get('destinationDisplay') || destination;
40
 
 
 
 
41
  // Search on mount and param changes
42
  const doSearch = useCallback(() => {
43
  if (!origin || !destination || !departureDate) return;
@@ -52,9 +58,30 @@ export default function ResultsPage() {
52
 
53
  useEffect(() => { doSearch(); }, [doSearch]);
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  // Client-side filter + sort the results
56
  const filteredFlights = useMemo(() => {
57
- let flights = showReturn ? returnFlights : outboundFlights;
58
 
59
  if (filters.max_stops !== null && filters.max_stops !== undefined) {
60
  flights = flights.filter(f => f.stops <= filters.max_stops!);
@@ -79,7 +106,17 @@ export default function ResultsPage() {
79
 
80
  // Sort
81
  if (sortBy === 'cheapest') {
82
- flights = [...flights].sort((a, b) => a.price_usd - b.price_usd);
 
 
 
 
 
 
 
 
 
 
83
  } else if (sortBy === 'fastest') {
84
  flights = [...flights].sort((a, b) => a.total_duration_minutes - b.total_duration_minutes);
85
  } else {
@@ -92,7 +129,7 @@ export default function ResultsPage() {
92
  }
93
 
94
  return flights;
95
- }, [outboundFlights, returnFlights, showReturn, filters, sortBy]);
96
 
97
  // Split into "best" and "other" flights
98
  const bestFlights = useMemo(() => filteredFlights.filter(f => f.is_best), [filteredFlights]);
@@ -120,7 +157,22 @@ export default function ResultsPage() {
120
  setSearchParams(params);
121
  setFilters(EMPTY_FILTERS);
122
  setSortBy('best');
123
- setShowReturn(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  }
125
 
126
  return (
@@ -140,33 +192,37 @@ export default function ResultsPage() {
140
  </div>
141
 
142
  <div className="mx-auto max-w-6xl px-4 py-6">
143
- {/* Round trip toggle */}
144
- {tripType === 'round_trip' && searched && returnFlights.length > 0 && (
145
- <div className="mb-4 flex gap-2 rounded-lg bg-white p-1 shadow-sm w-fit" data-testid="trip-toggle">
146
  <button
147
- onClick={() => setShowReturn(false)}
148
- className={`rounded-md px-4 py-2 text-sm font-medium cursor-pointer ${!showReturn ? 'bg-[#1a73e8] text-white' : 'text-gray-600 hover:bg-gray-50'}`}
149
- data-testid="toggle-outbound"
150
  >
151
- {origin} {destination}
152
- </button>
153
- <button
154
- onClick={() => setShowReturn(true)}
155
- className={`rounded-md px-4 py-2 text-sm font-medium cursor-pointer ${showReturn ? 'bg-[#1a73e8] text-white' : 'text-gray-600 hover:bg-gray-50'}`}
156
- data-testid="toggle-return"
157
- >
158
- {destination} → {origin}
159
  </button>
 
 
 
 
 
 
160
  </div>
161
  )}
162
 
 
 
 
 
 
163
  <div className="flex gap-6">
164
  {/* Filters sidebar */}
165
  {searched && outboundFlights.length > 0 && (
166
  <aside className="hidden lg:block w-64 flex-shrink-0">
167
  <div className="sticky top-4 rounded-lg bg-white p-4 shadow-sm">
168
  <FilterPanel
169
- flights={showReturn ? returnFlights : outboundFlights}
170
  filters={filters}
171
  onChange={setFilters}
172
  />
@@ -194,12 +250,12 @@ export default function ResultsPage() {
194
  <NoResults hasFilters={hasFilters} onClearFilters={() => setFilters(EMPTY_FILTERS)} />
195
  ) : (
196
  <div>
197
- {/* Best departing flights section */}
198
  {bestFlights.length > 0 && (
199
  <section className="mb-6" data-testid="best-flights-section">
200
  <div className="mb-2 flex items-center gap-2">
201
  <h2 className="text-sm font-medium text-gray-900">
202
- Best departing flights
203
  </h2>
204
  <div className="group relative">
205
  <svg className="h-4 w-4 text-gray-400 cursor-help" viewBox="0 0 20 20" fill="currentColor">
@@ -212,27 +268,45 @@ export default function ResultsPage() {
212
  </div>
213
  <div className="space-y-2" data-testid="best-flights-list">
214
  {bestFlights.map(flight => (
215
- <FlightCard key={flight.id} flight={flight} />
 
 
 
 
 
 
 
 
 
216
  ))}
217
  </div>
218
  </section>
219
  )}
220
 
221
- {/* Other departing flights section */}
222
  {otherFlights.length > 0 && (
223
  <section data-testid="other-flights-section">
224
  {bestFlights.length > 0 && (
225
  <div className="mb-2 mt-2 flex items-center gap-3">
226
  <div className="h-px flex-1 bg-gray-200" />
227
  <h2 className="text-sm font-medium text-gray-500 whitespace-nowrap">
228
- Other departing flights
229
  </h2>
230
  <div className="h-px flex-1 bg-gray-200" />
231
  </div>
232
  )}
233
  <div className="space-y-2" data-testid="other-flights-list">
234
  {otherFlights.map(flight => (
235
- <FlightCard key={flight.id} flight={flight} />
 
 
 
 
 
 
 
 
 
236
  ))}
237
  </div>
238
  </section>
 
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';
6
  import SearchForm from '../components/search/SearchForm';
7
  import type { SearchFormData } from '../components/search/SearchForm';
8
  import FlightCard from '../components/results/FlightCard';
9
  import FilterPanel from '../components/results/FilterPanel';
10
  import NoResults from '../components/results/NoResults';
11
+ import SelectedFlightBanner from '../components/results/SelectedFlightBanner';
12
  import SortBar from '../components/results/SortBar';
13
  import Loading from '../components/shared/Loading';
14
  import { useFlightSearch } from '../hooks/useFlightSearch';
 
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);
26
+ const [step, setStep] = useState<'outbound' | 'return'>('outbound');
27
+ const [selectedOutbound, setSelectedOutbound] = useState<FlightOffer | null>(null);
28
 
29
  // Parse URL params
30
  const origin = searchParams.get('origin') || '';
 
41
  const originDisplay = searchParams.get('originDisplay') || origin;
42
  const destinationDisplay = searchParams.get('destinationDisplay') || destination;
43
 
44
+ const isRoundTrip = tripType === 'round_trip' && returnFlights.length > 0;
45
+ const showingReturn = isRoundTrip && step === 'return' && selectedOutbound !== null;
46
+
47
  // Search on mount and param changes
48
  const doSearch = useCallback(() => {
49
  if (!origin || !destination || !departureDate) return;
 
58
 
59
  useEffect(() => { doSearch(); }, [doSearch]);
60
 
61
+ // Memoized: minimum round-trip price for each outbound flight
62
+ const outboundMinPrices = useMemo(() => {
63
+ if (!isRoundTrip) return new Map<string, number>();
64
+ const map = new Map<string, number>();
65
+ for (const ob of outboundFlights) {
66
+ const min = minRoundTripPrice(ob, returnFlights, sameAirlineDiscount);
67
+ if (min !== null) map.set(ob.id, min);
68
+ }
69
+ return map;
70
+ }, [outboundFlights, returnFlights, sameAirlineDiscount, isRoundTrip]);
71
+
72
+ // Memoized: round-trip total for each return flight (given selected outbound)
73
+ const returnRoundTripPrices = useMemo(() => {
74
+ if (!selectedOutbound) return new Map<string, number>();
75
+ const map = new Map<string, number>();
76
+ for (const ret of returnFlights) {
77
+ map.set(ret.id, roundTripTotal(selectedOutbound, ret, sameAirlineDiscount));
78
+ }
79
+ return map;
80
+ }, [returnFlights, selectedOutbound, sameAirlineDiscount]);
81
+
82
  // Client-side filter + sort the results
83
  const filteredFlights = useMemo(() => {
84
+ let flights = showingReturn ? returnFlights : outboundFlights;
85
 
86
  if (filters.max_stops !== null && filters.max_stops !== undefined) {
87
  flights = flights.filter(f => f.stops <= filters.max_stops!);
 
106
 
107
  // Sort
108
  if (sortBy === 'cheapest') {
109
+ if (showingReturn && selectedOutbound) {
110
+ flights = [...flights].sort((a, b) =>
111
+ (returnRoundTripPrices.get(a.id) ?? a.price_usd) - (returnRoundTripPrices.get(b.id) ?? b.price_usd)
112
+ );
113
+ } else if (isRoundTrip && !showingReturn) {
114
+ flights = [...flights].sort((a, b) =>
115
+ (outboundMinPrices.get(a.id) ?? a.price_usd) - (outboundMinPrices.get(b.id) ?? b.price_usd)
116
+ );
117
+ } else {
118
+ flights = [...flights].sort((a, b) => a.price_usd - b.price_usd);
119
+ }
120
  } else if (sortBy === 'fastest') {
121
  flights = [...flights].sort((a, b) => a.total_duration_minutes - b.total_duration_minutes);
122
  } else {
 
129
  }
130
 
131
  return flights;
132
+ }, [outboundFlights, returnFlights, showingReturn, isRoundTrip, selectedOutbound, outboundMinPrices, returnRoundTripPrices, filters, sortBy]);
133
 
134
  // Split into "best" and "other" flights
135
  const bestFlights = useMemo(() => filteredFlights.filter(f => f.is_best), [filteredFlights]);
 
157
  setSearchParams(params);
158
  setFilters(EMPTY_FILTERS);
159
  setSortBy('best');
160
+ setStep('outbound');
161
+ setSelectedOutbound(null);
162
+ }
163
+
164
+ function handleSelectOutbound(flight: FlightOffer) {
165
+ setSelectedOutbound(flight);
166
+ setStep('return');
167
+ setFilters(EMPTY_FILTERS);
168
+ setSortBy('best');
169
+ }
170
+
171
+ function handleChangeOutbound() {
172
+ setStep('outbound');
173
+ setSelectedOutbound(null);
174
+ setFilters(EMPTY_FILTERS);
175
+ setSortBy('best');
176
  }
177
 
178
  return (
 
192
  </div>
193
 
194
  <div className="mx-auto max-w-6xl px-4 py-6">
195
+ {/* Step breadcrumb for round trip */}
196
+ {isRoundTrip && searched && (
197
+ <div className="mb-4 flex items-center gap-2 text-sm" data-testid="step-breadcrumb">
198
  <button
199
+ onClick={handleChangeOutbound}
200
+ className={`font-medium cursor-pointer ${step === 'outbound' ? 'text-[#1a73e8]' : 'text-gray-500 hover:text-gray-700'}`}
201
+ data-testid="breadcrumb-outbound"
202
  >
203
+ 1. Select departing flight
 
 
 
 
 
 
 
204
  </button>
205
+ <svg className="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
206
+ <path fillRule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clipRule="evenodd"/>
207
+ </svg>
208
+ <span className={`font-medium ${step === 'return' ? 'text-[#1a73e8]' : 'text-gray-400'}`}>
209
+ 2. Select return flight
210
+ </span>
211
  </div>
212
  )}
213
 
214
+ {/* Selected outbound banner on return step */}
215
+ {showingReturn && selectedOutbound && (
216
+ <SelectedFlightBanner flight={selectedOutbound} onChangeClick={handleChangeOutbound} />
217
+ )}
218
+
219
  <div className="flex gap-6">
220
  {/* Filters sidebar */}
221
  {searched && outboundFlights.length > 0 && (
222
  <aside className="hidden lg:block w-64 flex-shrink-0">
223
  <div className="sticky top-4 rounded-lg bg-white p-4 shadow-sm">
224
  <FilterPanel
225
+ flights={showingReturn ? returnFlights : outboundFlights}
226
  filters={filters}
227
  onChange={setFilters}
228
  />
 
250
  <NoResults hasFilters={hasFilters} onClearFilters={() => setFilters(EMPTY_FILTERS)} />
251
  ) : (
252
  <div>
253
+ {/* Best flights section */}
254
  {bestFlights.length > 0 && (
255
  <section className="mb-6" data-testid="best-flights-section">
256
  <div className="mb-2 flex items-center gap-2">
257
  <h2 className="text-sm font-medium text-gray-900">
258
+ {showingReturn ? 'Best returning flights' : 'Best departing flights'}
259
  </h2>
260
  <div className="group relative">
261
  <svg className="h-4 w-4 text-gray-400 cursor-help" viewBox="0 0 20 20" fill="currentColor">
 
268
  </div>
269
  <div className="space-y-2" data-testid="best-flights-list">
270
  {bestFlights.map(flight => (
271
+ <FlightCard
272
+ key={flight.id}
273
+ flight={flight}
274
+ roundTripPrice={showingReturn
275
+ ? returnRoundTripPrices.get(flight.id)
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
  ))}
282
  </div>
283
  </section>
284
  )}
285
 
286
+ {/* Other flights section */}
287
  {otherFlights.length > 0 && (
288
  <section data-testid="other-flights-section">
289
  {bestFlights.length > 0 && (
290
  <div className="mb-2 mt-2 flex items-center gap-3">
291
  <div className="h-px flex-1 bg-gray-200" />
292
  <h2 className="text-sm font-medium text-gray-500 whitespace-nowrap">
293
+ {showingReturn ? 'Other returning flights' : 'Other departing flights'}
294
  </h2>
295
  <div className="h-px flex-1 bg-gray-200" />
296
  </div>
297
  )}
298
  <div className="space-y-2" data-testid="other-flights-list">
299
  {otherFlights.map(flight => (
300
+ <FlightCard
301
+ key={flight.id}
302
+ flight={flight}
303
+ roundTripPrice={showingReturn
304
+ ? returnRoundTripPrices.get(flight.id)
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
  ))}
311
  </div>
312
  </section>
frontend/src/utils/pricing.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FlightOffer } from '../api/types';
2
+
3
+ /** Check if two flights share at least one carrier. */
4
+ export function sharesAirline(a: FlightOffer, b: FlightOffer): boolean {
5
+ const airlinesA = new Set(a.segments.map(s => s.airline_code));
6
+ return b.segments.some(s => airlinesA.has(s.airline_code));
7
+ }
8
+
9
+ /** Return the effective price of a return flight given the selected outbound. */
10
+ export function effectiveReturnPrice(
11
+ ret: FlightOffer,
12
+ outbound: FlightOffer,
13
+ discountRate: number,
14
+ ): number {
15
+ if (sharesAirline(ret, outbound)) {
16
+ return Math.round(ret.price_usd * discountRate);
17
+ }
18
+ return ret.price_usd;
19
+ }
20
+
21
+ /** Total round-trip price for an outbound + return pair. */
22
+ export function roundTripTotal(
23
+ outbound: FlightOffer,
24
+ ret: FlightOffer,
25
+ discountRate: number,
26
+ ): number {
27
+ return outbound.price_usd + effectiveReturnPrice(ret, outbound, discountRate);
28
+ }
29
+
30
+ /** Minimum round-trip price for a given outbound across all returns. */
31
+ export function minRoundTripPrice(
32
+ outbound: FlightOffer,
33
+ allReturns: FlightOffer[],
34
+ discountRate: number,
35
+ ): number | null {
36
+ if (allReturns.length === 0) return null;
37
+ let min = Infinity;
38
+ for (const ret of allReturns) {
39
+ const total = roundTripTotal(outbound, ret, discountRate);
40
+ if (total < min) min = total;
41
+ }
42
+ return min;
43
+ }