@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.
- package/CHANGELOG.md +122 -0
- package/Checkout/Checkout.tsx +280 -0
- package/Checkout/Sidebar/Sidebar.tsx +110 -0
- package/Checkout/Sidebar/styles.ts +52 -0
- package/Found/Empty/Empty.tsx +109 -0
- package/Found/Empty/styles.ts +99 -0
- package/Found/Found.tsx +19 -2
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +13 -4
- package/Found/Route/Route.tsx +25 -14
- package/Found/Route/styles.ts +61 -23
- package/Found/refine.ts +26 -0
- package/RoutesOrder.tsx +69 -23
- package/Step2.tsx +34 -1
- package/Step4/Passenger/Passenger.tsx +10 -10
- package/Step5/Billing/Billing.tsx +8 -7
- package/Step5/Coupons/Coupons.tsx +23 -17
- package/Step5/Coupons/styles.ts +41 -8
- package/Step5/Payments/Payments.tsx +2 -2
- package/Step5/Payments/styles.ts +16 -7
- package/Step5/Summary/About/About.tsx +73 -17
- package/Step5/Summary/About/styles.ts +89 -1
- package/Steps/Steps.tsx +3 -1
- package/package.json +1 -1
- package/services.ts +46 -4
- package/styles.ts +77 -0
- package/types.ts +24 -0
- package/Step4/Step4.tsx +0 -114
- package/Step5/Step5.tsx +0 -185
|
@@ -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
|
-
{
|
|
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 }
|
package/Found/Header/styles.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
51
|
+
flex: 0 0 120px;
|
|
36
52
|
`;
|
|
37
53
|
|
|
38
54
|
export const Price = styled(Item)`
|
|
39
|
-
flex
|
|
55
|
+
flex: 0 0 165px;
|
|
40
56
|
`;
|
package/Found/Orders/Orders.tsx
CHANGED
|
@@ -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
|
-
//
|
|
34
|
-
//
|
|
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
|
-
<
|
|
46
|
+
<Empty step1={ step1 } data={ alternatives } loading={ alternativesLoading } t={ t } />
|
|
38
47
|
);
|
|
39
48
|
}
|
|
40
49
|
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
<
|
|
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
|
);
|
package/Found/Route/styles.ts
CHANGED
|
@@ -64,12 +64,24 @@ export const Company = styled.div`
|
|
|
64
64
|
flex-basis: 100%;
|
|
65
65
|
|
|
66
66
|
@media (min-width: 1024px) {
|
|
67
|
-
flex
|
|
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
|
|
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:
|
|
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
|
|
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: -
|
|
171
|
-
font-size: ${ props => props.theme.size.
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
{
|
|
131
|
-
<
|
|
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
|
-
|
|
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
|
-
{
|
|
60
|
+
{ title }
|
|
28
61
|
</Title>
|
|
29
62
|
|
|
30
63
|
<Steps value={ 2 } />
|