@autobusal/routes-order 1.9.3 → 1.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ import styled from 'styled-components';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ export const Container = styled.div`
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 20px;
8
+ `;
9
+
10
+ export const Headline = styled.div`
11
+ text-align: center;
12
+ `;
13
+
14
+ export const Title = styled.h3`
15
+ margin-bottom: 6px;
16
+ `;
17
+
18
+ export const Lead = styled.p`
19
+ margin: 0;
20
+ color: ${ props => props.theme.font.faded };
21
+ `;
22
+
23
+ export const Group = styled.div`
24
+ display: flex;
25
+ flex-direction: column;
26
+ gap: 10px;
27
+ `;
28
+
29
+ export const GroupTitle = styled.h6`
30
+ margin-bottom: 0;
31
+ font-size: ${ props => props.theme.size.xs };
32
+ text-transform: uppercase;
33
+ color: ${ props => props.theme.font.faded };
34
+ `;
35
+
36
+ export const Options = styled.div`
37
+ display: grid;
38
+ gap: 10px;
39
+ grid-template-columns: minmax(0, 1fr);
40
+
41
+ @media (min-width: 640px) {
42
+ grid-template-columns: repeat(3, minmax(0, 1fr));
43
+ }
44
+ `;
45
+
46
+ /**
47
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
48
+ *
49
+ * These links carry `reloadDocument` at the call site. RoutesOrder holds
50
+ * the search in component state and Step1 only kicks off a search when it
51
+ * has not already redirected, so a client-side navigation changes the URL
52
+ * and nothing else - the old, empty result set just stays on screen.
53
+ *
54
+ * A full load re-enters the flow from the URL exactly as arriving from a
55
+ * search engine does. Teaching RoutesOrder to resync its state from
56
+ * changing params would be the tidier fix, but that is the main booking
57
+ * flow and this is a dead-end page: not worth the risk for the gain.
58
+ */
59
+ export const Option = styled(Link)`
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 3px;
63
+ padding: 12px 15px;
64
+ border-radius: ${ props => props.theme.borderRadius };
65
+ border: 1px solid ${ props => props.theme.inputs.border };
66
+ background: ${ props => props.theme.background.normal };
67
+ transition: all 0.2s ease;
68
+
69
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
70
+ // These are cards, not sentences, so they lift and take the brand colour
71
+ // on hover rather than underlining. The global anchor rule underlines on
72
+ // hover, hence the explicit none.
73
+ &:hover {
74
+ text-decoration: none;
75
+ transform: translateY(-2px);
76
+ border-color: ${ props => props.theme.primary.normal };
77
+ box-shadow: ${ props => props.theme.boxShadow };
78
+ }
79
+
80
+ &:hover strong {
81
+ color: ${ props => props.theme.primary.normal };
82
+ }
83
+ `;
84
+
85
+ export const OptionName = styled.strong`
86
+ overflow: hidden;
87
+ text-overflow: ellipsis;
88
+ white-space: nowrap;
89
+ `;
90
+
91
+ export const OptionMeta = styled.span`
92
+ font-size: ${ props => props.theme.size.xs };
93
+ color: ${ props => props.theme.font.faded };
94
+ `;
95
+
96
+ export const OptionPrice = styled.span`
97
+ font-weight: 700;
98
+ font-size: ${ props => props.theme.size.s };
99
+ `;
package/Found/Found.tsx CHANGED
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
2
2
  import { useSearchParams } from 'react-router-dom';
3
3
  import { TFunction } from 'i18next';
4
4
  import Dates from './Dates/Dates';
5
- import Booking from './Booking/Booking';
6
5
  import Header from './Header/Header';
7
6
  import Orders from './Orders/Orders';
8
7
  import Sort from './Sort/Sort';
@@ -10,6 +9,7 @@ import Picks from './Picks/Picks';
10
9
  import Filters from './Filters/Filters';
11
10
  import Summary from './Summary/Summary';
12
11
  import { Results, Sidebar, Listing } from '../styles';
12
+ import { useGetAlternatives } from '../services';
13
13
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
14
14
  import { FoundData } from '@autobusal/providers/types/routes';
15
15
  import { Refinement, BucketKey, fromParams, toParams, filterItems, buildFacets, priceRange, currencyOf, isRefined } from './refine';
@@ -73,6 +73,14 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
73
73
 
74
74
  const passengers = Number(step1.adults ?? 0) + Number(step1.children ?? 0) + Number(step1.babies ?? 0);
75
75
 
76
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
77
+ // Only fetched once the search itself has finished and come back empty,
78
+ // so a search that found something never pays for the alternatives.
79
+ const { data: alternatives, isFetching: alternativesLoading } = useGetAlternatives(
80
+ step1,
81
+ !loading && items.length === 0
82
+ );
83
+
76
84
  // controls are pointless while there is nothing (or only one thing) to
77
85
  // refine, and they'd only add noise above an empty state
78
86
  const showControls = !loading && items.length > 1;
@@ -81,7 +89,13 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
81
89
  <>
82
90
  <Dates type={ type } step1={ step1 } t={ t } onSearch={ onSearch } />
83
91
 
84
- { (data !== undefined && data.length > 0) && <Booking data={ data } step1={ step1 } t={ t } /> }
92
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
93
+ The Booking.com banner is gone from the results. It sat between
94
+ the date strip and the trips, pushing the actual results below
95
+ it and advertising a hotel to somebody still deciding whether
96
+ they can travel at all. The component is kept, unused, so the
97
+ affiliate placement can be reinstated somewhere it does not
98
+ interrupt the search. */ }
85
99
 
86
100
  { showControls && (
87
101
  <>
@@ -121,6 +135,9 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
121
135
  loading={ loading }
122
136
  data={ refined }
123
137
  empty={ items.length === 0 }
138
+ step1={ step1 }
139
+ alternatives={ alternatives }
140
+ alternativesLoading={ alternativesLoading }
124
141
  refined={ isRefined(refinement) }
125
142
  sort={ refinement.sort }
126
143
  type={ type }
@@ -1,8 +1,21 @@
1
1
  import styled from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
5
+ *
6
+ * This row only means anything if its labels sit over the columns they
7
+ * name, so its geometry has to match the result card's exactly: the same
8
+ * 15px horizontal padding and the same 15px gap between columns. It had
9
+ * 4px padding and no gap at all, which pushed every label right of the
10
+ * data it labelled - Duration was out by 34px.
11
+ *
12
+ * If Found/Route/styles.ts ever changes the card's padding or the Trip
13
+ * gap, this has to move with it.
14
+ */
3
15
  export const Container = styled.div`
4
16
  display: none;
5
- padding: 8px 4px;
17
+ gap: 15px;
18
+ padding: 8px 15px;
6
19
  margin-bottom: 15px;
7
20
 
8
21
  @media (min-width: 1024px) {
@@ -23,18 +36,21 @@ const Item = styled.div`
23
36
  }
24
37
  `;
25
38
 
39
+ // these five must stay identical to their counterparts in
40
+ // Found/Route/styles.ts, or the labels stop sitting over their columns
26
41
  export const Company = styled(Item)`
27
- flex-basis: 185px;
42
+ flex: 0 0 185px;
28
43
  `;
29
44
 
30
45
  export const Location = styled(Item)`
31
46
  flex: 1;
47
+ min-width: 0;
32
48
  `;
33
49
 
34
50
  export const Time = styled(Item)`
35
- flex-basis: 120px;
51
+ flex: 0 0 120px;
36
52
  `;
37
53
 
38
54
  export const Price = styled(Item)`
39
- flex-basis: 165px;
55
+ flex: 0 0 165px;
40
56
  `;
@@ -2,10 +2,13 @@ import { TFunction } from 'i18next';
2
2
  import { Inline } from '@autobusal/common';
3
3
  import Route from '../Route/Route';
4
4
  import Group from '../Group/Group';
5
+ import Empty from '../Empty/Empty';
5
6
  import { getFavorites, getNormal } from './utilities';
6
7
  import { sortItems, groupItems, SortKey } from '../refine';
7
8
  import { Container, ContainerNotFound, Reset } from './styles';
8
9
  import { FoundData } from '@autobusal/providers/types/routes';
10
+ import { RoutesSearchForm } from '@autobusal/routes-search/types';
11
+ import { AlternativesData } from '../../types';
9
12
 
10
13
  interface Props {
11
14
  loading: boolean
@@ -16,12 +19,15 @@ interface Props {
16
19
  type: 'departure' | '_return'
17
20
  passengers: number
18
21
  preferredStop?: number
22
+ step1: RoutesSearchForm
23
+ alternatives?: AlternativesData
24
+ alternativesLoading: boolean
19
25
  t: TFunction<'common'>
20
26
  onReset: () => void
21
27
  onSave: (data: FoundData) => void
22
28
  }
23
29
 
24
- const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferredStop, t, onReset, onSave }: Props): JSX.Element => {
30
+ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferredStop, step1, alternatives, alternativesLoading, t, onReset, onSave }: Props): JSX.Element => {
25
31
  if (loading) {
26
32
  return (
27
33
  <div className="box">
@@ -30,11 +36,14 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
30
36
  );
31
37
  }
32
38
 
33
- // no results at all - the search itself found nothing (or errored, e.g.
34
- // same origin and destination) - show the not-found message
39
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
40
+ // No results at all - the search itself found nothing (or errored, e.g.
41
+ // same origin and destination). Rather than a bare apology, offer the
42
+ // nearby origins, nearby destinations and other dates that obtapi has
43
+ // confirmed actually have seats.
35
44
  if (empty) {
36
45
  return (
37
- <ContainerNotFound className="box">{ t('routes_order.step2.not_found') }</ContainerNotFound>
46
+ <Empty step1={ step1 } data={ alternatives } loading={ alternativesLoading } t={ t } />
38
47
  );
39
48
  }
40
49
 
@@ -3,9 +3,9 @@ import { TFunction } from 'i18next';
3
3
  import { HiOutlineArrowNarrowRight, HiOutlineDocumentText } from 'react-icons/hi';
4
4
  import { BiHourglass } from 'react-icons/bi';
5
5
  import { RiBus2Line } from 'react-icons/ri';
6
- import { formatDuration } from '../refine';
6
+ import { formatDuration, splitPrice } from '../refine';
7
7
  import { Button, RouteFeature, Policy } from '@autobusal/common';
8
- import { Container, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, PriceNotice, LinkCompany, Details, Detail, ButtonPolicy, TitleDetail, LinkRoute, Features, TitleFeatures, FeaturesItems } from './styles';
8
+ import { Container, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, Details, Detail, ButtonPolicy, TitleDetail, LinkRoute, FeaturesItems } from './styles';
9
9
  import { FoundData } from '@autobusal/providers/types/routes';
10
10
 
11
11
  // neutral placeholder so a missing/broken operator logo doesn't render the
@@ -30,8 +30,10 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
30
30
  const customs = Object.values(data.customs).join(', ');
31
31
  const transiting = Object.values(data.transiting).join(', ');
32
32
 
33
+ const price = splitPrice(data.price.display);
34
+
33
35
  const features = data.trip.features_data.map((item, index) => (
34
- <RouteFeature key={ index } item={ item } />
36
+ <RouteFeature key={ index } item={ item } compact />
35
37
  ));
36
38
 
37
39
  // Edited: Ferjolt Ozuni - Date: 2026-07-27
@@ -86,7 +88,11 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
86
88
  </Location>
87
89
 
88
90
  <Price>
89
- <strong>{ data.price.display }</strong>
91
+ <Amount>
92
+ { price.whole }
93
+ { price.decimals !== null && <Decimals>{ price.decimals }</Decimals> }
94
+ { price.currency !== null && <Currency>{ price.currency }</Currency> }
95
+ </Amount>
90
96
 
91
97
  { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
92
98
  obtapi's price.value is the total for the WHOLE party
@@ -121,6 +127,21 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
121
127
  { children }
122
128
 
123
129
  <Details>
130
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
131
+ Amenities were a separate full-width block under the card.
132
+ They are the same kind of thing as route, transit and policies
133
+ - supporting detail - so they join that row as the first
134
+ column, which is one fewer band of vertical space per result. */ }
135
+ { data.trip.features_data.length > 0 && (
136
+ <Detail>
137
+ <TitleDetail>{ t('routes_order.step2.route.features') }</TitleDetail>
138
+
139
+ <FeaturesItems>
140
+ { features }
141
+ </FeaturesItems>
142
+ </Detail>
143
+ ) }
144
+
124
145
  <Detail>
125
146
  <TitleDetail>{ t('routes_order.step2.route.name') }</TitleDetail>
126
147
  <LinkRoute to={ bookOnUrl ?? data.link } target="_blank" rel="noopener noreferrer">{ data.code }</LinkRoute>
@@ -152,16 +173,6 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
152
173
  ) }
153
174
  </Details>
154
175
 
155
- { data.trip.features_data.length > 0 && (
156
- <Features>
157
- <TitleFeatures>{ t('routes_order.step2.route.features') }</TitleFeatures>
158
-
159
- <FeaturesItems>
160
- { features }
161
- </FeaturesItems>
162
- </Features>
163
- ) }
164
-
165
176
  { policy && typeof data.id === 'number' && <Policy id={ data.id } t={ t } onClose={ () => setPolicy(false) } /> }
166
177
  </Container>
167
178
  );
@@ -64,12 +64,24 @@ export const Company = styled.div`
64
64
  flex-basis: 100%;
65
65
 
66
66
  @media (min-width: 1024px) {
67
- flex-basis: 185px;
67
+ flex: 0 0 185px;
68
68
  }
69
69
  `;
70
70
 
71
+ /**
72
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
73
+ *
74
+ * min-width: 0 is what makes `flex: 1` mean "an equal share". Without it
75
+ * the default min-width:auto stops a column shrinking below its content,
76
+ * so the arrival column (long city plus a long stop name) grew to 215px
77
+ * while the departure column got 155px - the two halves of the same row
78
+ * were different widths, and no fixed header could ever line up with
79
+ * both. It also stops one long stop name stealing width from the other
80
+ * side of the card.
81
+ */
71
82
  export const Location = styled.div`
72
83
  flex: 1;
84
+ min-width: 0;
73
85
  display: flex;
74
86
  flex-direction: column;
75
87
  align-items: center;
@@ -146,7 +158,7 @@ export const Time = styled.div`
146
158
  }
147
159
 
148
160
  @media (min-width: 640px) {
149
- flex-basis: 120px;
161
+ flex: 0 0 120px;
150
162
  }
151
163
  `;
152
164
 
@@ -154,21 +166,52 @@ export const Price = styled.div`
154
166
  flex-basis: 100%;
155
167
  display: flex;
156
168
  flex-direction: column;
157
- gap: 10px;
169
+ gap: 8px;
158
170
  align-items: center;
159
171
 
160
- & > strong {
161
- font-size: ${ props => props.theme.size.xxl };
162
- }
163
-
164
172
  @media (min-width: 768px) {
165
- flex-basis: 165px;
173
+ flex: 0 0 165px;
166
174
  }
167
175
  `;
168
176
 
177
+ /**
178
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
179
+ *
180
+ * The decimals ride as superscript rather than at full size. On a fare
181
+ * board the cents are almost never what anyone is comparing, and at the
182
+ * headline size they were taking a third of the width of the widest thing
183
+ * on the card. The line-height is pinned to 1 so the shrunk decimals do
184
+ * not leave a gap under the row.
185
+ */
186
+ export const Amount = styled.strong`
187
+ display: flex;
188
+ align-items: flex-start;
189
+ line-height: 1;
190
+ font-size: ${ props => props.theme.size.xxl };
191
+ `;
192
+
193
+ export const Decimals = styled.span`
194
+ margin-left: 1px;
195
+ font-size: ${ props => props.theme.size.s };
196
+ line-height: 1;
197
+ `;
198
+
199
+ export const Currency = styled.span`
200
+ margin-left: 4px;
201
+ align-self: center;
202
+ font-size: ${ props => props.theme.size.m };
203
+ `;
204
+
205
+ /**
206
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
207
+ *
208
+ * Tucked up against the price it qualifies, which reads as one unit and
209
+ * frees the vertical space the Select button needs.
210
+ */
169
211
  export const PriceNotice = styled.span`
170
- margin-top: -6px;
171
- font-size: ${ props => props.theme.size.xs };
212
+ margin-top: -4px;
213
+ font-size: ${ props => props.theme.size.xxs };
214
+ line-height: 1.25;
172
215
  color: ${ props => props.theme.font.faded };
173
216
  text-align: center;
174
217
  `;
@@ -235,20 +278,15 @@ export const ButtonPolicy = styled.button`
235
278
  }
236
279
  `;
237
280
 
238
- export const Features = styled.div`
239
- display: flex;
240
- flex-direction: column;
241
- align-items: center;
242
- `;
243
-
244
- export const TitleFeatures = styled.h6`
245
- font-size: ${ props => props.theme.size.s };
246
- margin-bottom: 8px;
247
- `;
248
-
281
+ /**
282
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
283
+ *
284
+ * Left-aligned now that this sits inside a Detail column alongside route,
285
+ * transit and policies, rather than centred in a full-width band of its
286
+ * own under the card.
287
+ */
249
288
  export const FeaturesItems = styled.div`
250
289
  display: flex;
251
290
  flex-wrap: wrap;
252
- gap: 8px;
253
- justify-content: center;
291
+ gap: 6px;
254
292
  `;
package/Found/refine.ts CHANGED
@@ -140,6 +140,32 @@ export const formatDuration = (duration: string, t: TFunction<'common'>): string
140
140
  return parts.join(' ');
141
141
  };
142
142
 
143
+ /**
144
+ * Split obtapi's formatted price into its parts so the decimals can be set
145
+ * as superscript.
146
+ *
147
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
148
+ *
149
+ * obtapi sends a rendered string ("1,048.00 EUR" - number_format, so the
150
+ * thousands separator is a comma and the decimal point a dot). Splitting it
151
+ * here rather than reformatting from `price.value` keeps the label's own
152
+ * currency and formatting as the single source of truth; we only decide how
153
+ * big each piece is drawn.
154
+ *
155
+ * Anything that does not match the expected shape is returned whole, so an
156
+ * unusual currency format degrades to today's rendering instead of being
157
+ * mangled.
158
+ */
159
+ export const splitPrice = (display: string): { whole: string, decimals: string | null, currency: string | null } => {
160
+ const match = /^(.+)\.(\d{2})\s+(\S+)$/.exec(display ?? '');
161
+
162
+ if (match === null) {
163
+ return { whole: display ?? '', decimals: null, currency: null };
164
+ }
165
+
166
+ return { whole: match[1], decimals: match[2], currency: match[3] };
167
+ };
168
+
143
169
  export const bucketOf = (item: FoundData): BucketKey => {
144
170
  const hour = Math.floor(departureOf(item) / 60);
145
171
 
package/RoutesOrder.tsx CHANGED
@@ -1,13 +1,17 @@
1
1
  import { useState } from 'react';
2
2
  import { useParams } from 'react-router-dom';
3
3
  import { TFunction } from 'i18next';
4
+ import { AiOutlineSearch, AiOutlineCaretDown, AiOutlineCaretUp } from 'react-icons/ai';
4
5
  import { Meta } from '@autobusal/common';
6
+ import { useSystemTheme } from '@autobusal/hooks';
7
+ import { useGetSettings } from '@autobusal/providers/services';
8
+ import RoutesSearch from '@autobusal/routes-search';
5
9
  import { useGetSuggestions } from '@autobusal/routes-search/services';
10
+ import { SearchAgain, SearchInner, SearchToggle, SearchSummary, SearchPanel } from './styles';
6
11
  import Step1 from './Step1/Step1';
7
12
  import Step2 from './Step2';
8
13
  import Step3 from './Step3';
9
- import Step4 from './Step4/Step4';
10
- import Step5 from './Step5/Step5';
14
+ import Checkout from './Checkout/Checkout';
11
15
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
12
16
  import { FoundData } from '@autobusal/providers/types/routes';
13
17
  import { PersonData } from '@autobusal/providers/types/persons';
@@ -30,6 +34,12 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
30
34
  // search form (no :from/:to params yet) or if a slug doesn't resolve.
31
35
  const { data: cities } = useGetSuggestions();
32
36
 
37
+ const { data: settings } = useGetSettings();
38
+
39
+ // same artwork the homepage hero uses, so the results page reads as the
40
+ // same site rather than a bare form on a white band
41
+ const mode = useSystemTheme();
42
+
33
43
  const fromCity = cities.find(city => city.slug === params.from);
34
44
  const toCity = cities.find(city => city.slug === params.to);
35
45
 
@@ -41,6 +51,7 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
41
51
  ? t('routes_order.meta.description_pair', { from: fromCity.name, to: toCity.name })
42
52
  : t('routes_order.meta.description');
43
53
 
54
+ const [ searchOpen, setSearchOpen ] = useState<boolean>(false);
44
55
  const [ redirected, setRedirected ] = useState<boolean>(false);
45
56
  const [ current, setCurrent ] = useState<number>(1);
46
57
  const [ step1, setStep1 ] = useState<RoutesSearchForm | undefined>(undefined);
@@ -80,11 +91,6 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
80
91
  setCurrent(4);
81
92
  };
82
93
 
83
- const onStep4 = (data: PersonData): void => {
84
- setStep4(data);
85
- setCurrent(5);
86
- };
87
-
88
94
  const onBack = (step: number): void => {
89
95
  setCurrent(step);
90
96
  };
@@ -92,11 +98,62 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
92
98
  const showStep1 = current === 1;
93
99
  const showStep2 = current === 2 && step1 !== undefined;
94
100
  const showStep3 = current === 3 && step1 !== undefined && step2 !== undefined;
95
- const showStep4 = current === 4 && step1 !== undefined && step2 !== undefined;
96
- const showStep5 = current === 5 && step1 !== undefined && step2 !== undefined && step4 !== undefined;
101
+
102
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
103
+ // Step 4 is now the whole of the rest: passenger details and payment on
104
+ // one screen, so there is no step 5 and no intermediate handoff of
105
+ // passenger data between them.
106
+ const showCheckout = current === 4 && step1 !== undefined && step2 !== undefined;
107
+
108
+ /**
109
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
110
+ *
111
+ * The search form stays on screen while the user is choosing a route.
112
+ * Adjusting a date or a city used to mean "Back to Search", which threw
113
+ * away the results and any filters with them.
114
+ *
115
+ * Only on the route-selection screens: past that point a passenger has
116
+ * committed to a specific trip, and quietly re-running the search under
117
+ * them would discard passenger details they had already typed.
118
+ *
119
+ * onStep1 is the same handler Step1 uses, so submitting here re-runs the
120
+ * search and lands back on step 2 exactly as a fresh search would.
121
+ */
122
+ const showSearchAgain = (current === 2 || current === 3) && step1 !== undefined;
97
123
 
98
124
  return (
99
125
  <Meta title={ title } description={ description }>
126
+ { showSearchAgain && step1 !== undefined && (
127
+ <SearchAgain $background={ `${ settings.url }/home/background-${ mode }.jpg` }>
128
+ <SearchInner>
129
+ <SearchToggle type="button" aria-expanded={ searchOpen } onClick={ () => setSearchOpen(!searchOpen) }>
130
+ <AiOutlineSearch />
131
+
132
+ <SearchSummary>
133
+ { fromCity?.name ?? step1.from } → { toCity?.name ?? step1.to } · { step1.departure }
134
+ </SearchSummary>
135
+
136
+ { searchOpen ? <AiOutlineCaretUp /> : <AiOutlineCaretDown /> }
137
+ </SearchToggle>
138
+
139
+ <SearchPanel $open={ searchOpen }>
140
+ <RoutesSearch
141
+ type={ step1.type ?? 'departure' }
142
+ from={ `${ step1.from }${ step1.stop_from ? `:${ step1.stop_from }` : '' }` }
143
+ to={ `${ step1.to }${ step1.stop_to ? `:${ step1.stop_to }` : '' }` }
144
+ departure={ step1.departure }
145
+ _return={ step1._return ?? '' }
146
+ adultsNo={ step1.adults }
147
+ childrenNo={ step1.children }
148
+ babiesNo={ step1.babies }
149
+ t={ t }
150
+ onSearch={ data => { setSearchOpen(false); onStep1(data); } }
151
+ />
152
+ </SearchPanel>
153
+ </SearchInner>
154
+ </SearchAgain>
155
+ ) }
156
+
100
157
  { showStep1 && (
101
158
  <Step1
102
159
  value={ step1 }
@@ -127,25 +184,14 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
127
184
  />
128
185
  ) }
129
186
 
130
- { showStep4 && (
131
- <Step4
187
+ { showCheckout && (
188
+ <Checkout
132
189
  values={ step4 }
133
190
  step1={ step1 }
134
191
  step2={ step2 }
135
192
  step3={ step3 }
136
193
  t={ t }
137
- onSave={ onStep4 }
138
- onBack={ onBack }
139
- />
140
- ) }
141
-
142
- { showStep5 && (
143
- <Step5
144
- step1={ step1 }
145
- step2={ step2 }
146
- step3={ step3 }
147
- step4={ step4 }
148
- t={ t }
194
+ onStore={ setStep4 }
149
195
  onBack={ onBack }
150
196
  />
151
197
  ) }
package/Step2.tsx CHANGED
@@ -8,6 +8,7 @@ import { Title, Actions } from './styles';
8
8
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
9
9
  import { FoundData } from '@autobusal/providers/types/routes';
10
10
  import { useGetDepartures } from './services';
11
+ import { useGetSuggestions } from '@autobusal/routes-search/services';
11
12
 
12
13
  interface Props {
13
14
  step1: RoutesSearchForm
@@ -20,11 +21,43 @@ interface Props {
20
21
  const Step2 = ({ step1, t, onSearch, onSave, onBack }: Props): JSX.Element => {
21
22
  const { data, isFetching } = useGetDepartures(step1);
22
23
 
24
+ /**
25
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
26
+ *
27
+ * "Select a Departure Schedule" said nothing about the trip and nothing
28
+ * a search engine could use. The heading now names the actual journey.
29
+ *
30
+ * Countries are dropped when both ends share one: "from Tirana Albania
31
+ * to Durres Albania" is noise, while on a cross-border trip the country
32
+ * is the part that matters.
33
+ *
34
+ * Read from the city list rather than from the results, so the heading
35
+ * is right even when the search finds nothing - which is exactly when
36
+ * being told what was searched for matters most. That list is the same
37
+ * cached query the search form's autocomplete already uses, so it costs
38
+ * no extra request.
39
+ */
40
+ const { data: cities } = useGetSuggestions();
41
+
42
+ const from = cities.find(city => city.slug === step1.from);
43
+ const to = cities.find(city => city.slug === step1.to);
44
+
45
+ const sameCountry = from?.country?.id !== undefined && from.country.id === to?.country?.id;
46
+
47
+ const title = from && to
48
+ ? t('routes_order.step2.title_pair', {
49
+ from: from.name,
50
+ to: to.name,
51
+ from_country: sameCountry ? '' : ` ${ from.country?.name ?? '' }`,
52
+ to_country: sameCountry ? '' : ` ${ to.country?.name ?? '' }`
53
+ })
54
+ : t('routes_order.step2.title');
55
+
23
56
  return (
24
57
  <>
25
58
  <Title>
26
59
  <RiBus2Line />
27
- { t('routes_order.step2.title') }
60
+ { title }
28
61
  </Title>
29
62
 
30
63
  <Steps value={ 2 } />