@autobusal/routes-order 1.8.0 → 1.9.1
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 +731 -0
- package/Checkout/Checkout.tsx +519 -0
- package/Checkout/Sidebar/Sidebar.tsx +124 -0
- package/Checkout/Sidebar/styles.ts +52 -0
- package/Facts/Facts.tsx +271 -0
- package/Facts/questions.ts +250 -0
- package/Facts/service.ts +111 -0
- package/Facts/services.ts +19 -0
- package/Facts/styles.ts +112 -0
- package/Facts/types.ts +167 -0
- package/Faq/Faq.tsx +52 -0
- package/Faq/styles.ts +68 -0
- package/Found/Dates/Dates.tsx +95 -5
- package/Found/Dates/styles.ts +173 -9
- package/Found/Empty/Empty.tsx +136 -0
- package/Found/Empty/styles.ts +98 -0
- package/Found/Filters/Filters.tsx +194 -0
- package/Found/Filters/styles.ts +188 -0
- package/Found/Found.tsx +207 -17
- package/Found/Group/Group.tsx +68 -0
- package/Found/Group/styles.ts +44 -0
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +89 -27
- package/Found/Orders/styles.ts +14 -0
- package/Found/Picks/Picks.tsx +75 -0
- package/Found/Picks/styles.ts +106 -0
- package/Found/Route/Route.tsx +211 -33
- package/Found/Route/styles.ts +491 -46
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/Sort.tsx +40 -0
- package/Found/Sort/styles.ts +87 -0
- package/Found/Summary/Summary.tsx +49 -0
- package/Found/Summary/styles.ts +42 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/Found/refine.ts +652 -0
- package/Links/Links.tsx +91 -0
- package/Links/styles.ts +75 -0
- package/RoutesOrder.tsx +239 -31
- package/Schedule/Schedule.tsx +123 -0
- package/Schedule/styles.ts +71 -0
- package/Sections/Schema.tsx +186 -0
- package/Sections/Sections.tsx +160 -0
- package/Sections/styles.ts +44 -0
- package/Step1/prepare.ts +21 -2
- package/Step2.tsx +34 -1
- package/Step4/Passenger/Passenger.tsx +253 -24
- package/Step4/Passenger/styles.ts +21 -1
- package/Step4/Passengers/Display.tsx +10 -2
- package/Step4/Passengers/Passengers.tsx +53 -4
- package/Step4/Saved/Choose.tsx +99 -0
- package/Step4/Saved/Remember.tsx +58 -0
- package/Step4/Saved/styles.ts +87 -0
- package/Step4/checkAge.ts +15 -4
- package/Step5/Addons/Addons.tsx +146 -4
- package/Step5/Addons/styles.ts +94 -0
- package/Step5/Billing/Billing.tsx +138 -70
- package/Step5/Coupons/Coupons.tsx +28 -18
- package/Step5/Coupons/styles.ts +41 -8
- package/Step5/Payments/Payments.tsx +36 -5
- package/Step5/Payments/styles.ts +16 -7
- package/Step5/Summary/About/About.tsx +99 -27
- package/Step5/Summary/About/styles.ts +118 -1
- package/Step5/Summary/styles.ts +12 -5
- package/Step5/utilities.ts +67 -12
- package/Steps/Steps.tsx +3 -1
- package/commerce.ts +24 -0
- package/package.json +1 -1
- package/services.ts +302 -13
- package/styles.ts +126 -0
- package/types.ts +84 -1
- package/Found/Booking/Booking.tsx +0 -61
- package/Found/Booking/styles.ts +0 -50
- package/Step4/Step4.tsx +0 -114
- package/Step5/Step5.tsx +0 -185
package/Found/Dates/styles.ts
CHANGED
|
@@ -1,31 +1,150 @@
|
|
|
1
1
|
import styled, { css } from 'styled-components';
|
|
2
2
|
|
|
3
3
|
export const Container = styled.div`
|
|
4
|
-
overflow-x: auto;
|
|
5
4
|
margin-bottom: 15px;
|
|
6
|
-
padding:
|
|
5
|
+
padding: 8px 6px;
|
|
7
6
|
background-color: ${ props => props.theme.background.neutral };
|
|
8
7
|
border-radius: ${ props => props.theme.borderRadius };
|
|
8
|
+
|
|
9
|
+
@media (min-width: 640px) {
|
|
10
|
+
padding: 10px 15px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
15
|
+
* The scroll container only exists where the fixed-width strip below does,
|
|
16
|
+
* which is desktop. It was switched on from 640px, so a tablet inherited a
|
|
17
|
+
* scrollbar for a strip that had every reason to fit.
|
|
18
|
+
*/
|
|
19
|
+
@media (min-width: 1024px) {
|
|
20
|
+
overflow-x: auto;
|
|
21
|
+
}
|
|
9
22
|
`;
|
|
10
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
26
|
+
*
|
|
27
|
+
* The 900px floor is what forced the strip to scroll sideways on a phone -
|
|
28
|
+
* seven days plus two arrows in a box the screen could not hold. A week is a
|
|
29
|
+
* shape people read at a glance, and a shape you have to drag is not one:
|
|
30
|
+
* half the week was simply invisible, including whichever day was cheapest.
|
|
31
|
+
*
|
|
32
|
+
* The floor now applies only from 640px up, where there is room for it.
|
|
33
|
+
* Below that the days share the width and the month is dropped from each
|
|
34
|
+
* label (see Month) so seven of them fit.
|
|
35
|
+
*/
|
|
11
36
|
export const Inner = styled.div`
|
|
12
|
-
min-width: 900px;
|
|
13
37
|
display: flex;
|
|
14
|
-
gap:
|
|
38
|
+
gap: 2px;
|
|
15
39
|
justify-content: center;
|
|
16
40
|
align-items: center;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The two week-stepping arrows, squeezed on a phone. At their default size
|
|
44
|
+
* they took 70px of a 355px row between them - a fifth of the week's worth
|
|
45
|
+
* of space spent on two chevrons, which left each day 34px and forced the
|
|
46
|
+
* prices to wrap three times.
|
|
47
|
+
*/
|
|
48
|
+
& > button:first-child,
|
|
49
|
+
& > button:last-child {
|
|
50
|
+
flex: 0 0 auto;
|
|
51
|
+
padding: 0 6px;
|
|
52
|
+
height: 30px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@media (min-width: 640px) {
|
|
56
|
+
gap: 5px;
|
|
57
|
+
|
|
58
|
+
& > button:first-child,
|
|
59
|
+
& > button:last-child {
|
|
60
|
+
padding: 0 20px;
|
|
61
|
+
height: auto;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
67
|
+
*
|
|
68
|
+
* The 900px floor belongs to the SEVEN-day strip, and seven days only
|
|
69
|
+
* appear from 1024px. Applied from 640px it forced a tablet showing FIVE
|
|
70
|
+
* days to scroll sideways for a row that fitted comfortably - the strip
|
|
71
|
+
* was reserving width for two cells that were not being rendered.
|
|
72
|
+
*/
|
|
73
|
+
@media (min-width: 1024px) {
|
|
74
|
+
min-width: 900px;
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
80
|
+
*
|
|
81
|
+
* The WEEKDAY is what goes below a desktop, not the month.
|
|
82
|
+
*
|
|
83
|
+
* With three or five days showing rather than seven, a date needs to say
|
|
84
|
+
* which date it is more than it needs to say which weekday - "04 AUG" is
|
|
85
|
+
* unambiguous on its own, while "Tue 04" leaves somebody counting forward
|
|
86
|
+
* from today to work out whether that is this week or next. On a full
|
|
87
|
+
* seven-day strip the weekday earns its place, because the row reads as a
|
|
88
|
+
* week; on three days it does not.
|
|
89
|
+
*/
|
|
90
|
+
export const Weekday = styled.span`
|
|
91
|
+
display: none;
|
|
92
|
+
|
|
93
|
+
@media (min-width: 1024px) {
|
|
94
|
+
display: inline;
|
|
95
|
+
}
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
export const DayNumber = styled.span`
|
|
99
|
+
display: block;
|
|
100
|
+
line-height: 1.2;
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
104
|
+
*
|
|
105
|
+
* NO JOINING COMMA. This carried a ::before comma above 1024px, to read
|
|
106
|
+
* "Mon, 03 Aug" on one line. The cells are narrow enough
|
|
107
|
+
* that the weekday and the date wrap anyway, and the comma then led the
|
|
108
|
+
* second line - every desktop cell rendered as "Mon" over ", 03 Aug".
|
|
109
|
+
*
|
|
110
|
+
* Punctuation that only works when the text happens not to wrap is
|
|
111
|
+
* punctuation that will keep breaking; the two parts read perfectly well
|
|
112
|
+
* stacked without it.
|
|
113
|
+
*/
|
|
114
|
+
@media (min-width: 1024px) {
|
|
115
|
+
display: inline;
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
export const Month = styled.span`
|
|
120
|
+
text-transform: uppercase;
|
|
121
|
+
|
|
122
|
+
@media (min-width: 1024px) {
|
|
123
|
+
text-transform: none;
|
|
124
|
+
}
|
|
17
125
|
`;
|
|
18
126
|
|
|
19
127
|
export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean }>`
|
|
20
|
-
flex: 1;
|
|
21
|
-
|
|
22
|
-
|
|
128
|
+
flex: 1 1 0;
|
|
129
|
+
min-width: 0;
|
|
130
|
+
overflow: hidden;
|
|
131
|
+
display: flex;
|
|
132
|
+
flex-direction: column;
|
|
133
|
+
align-items: center;
|
|
134
|
+
justify-content: center;
|
|
135
|
+
gap: 2px;
|
|
136
|
+
padding: 4px 0;
|
|
137
|
+
min-height: 26px;
|
|
23
138
|
font-weight: 700;
|
|
24
139
|
text-align: center;
|
|
25
|
-
font-size: ${ props => props.theme.size.
|
|
26
|
-
border-radius:
|
|
140
|
+
font-size: ${ props => props.theme.size.xs };
|
|
141
|
+
border-radius: 12px;
|
|
27
142
|
transition: all 0.3s ease;
|
|
28
143
|
|
|
144
|
+
@media (min-width: 640px) {
|
|
145
|
+
font-size: ${ props => props.theme.size.s };
|
|
146
|
+
}
|
|
147
|
+
|
|
29
148
|
${ props => !props.$available && css`
|
|
30
149
|
opacity: .6;
|
|
31
150
|
` }
|
|
@@ -41,6 +160,51 @@ export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean
|
|
|
41
160
|
` }
|
|
42
161
|
`;
|
|
43
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
165
|
+
*
|
|
166
|
+
* Inherits colour from the selected/hovered day rather than setting its own,
|
|
167
|
+
* so the price stays readable once the parent flips to the primary fill.
|
|
168
|
+
*/
|
|
169
|
+
export const DayPrice = styled.span<{ $cheapest: boolean }>`
|
|
170
|
+
/**
|
|
171
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
172
|
+
* Allowed to WRAP on a phone. "38.00 EUR" is wider than the ~45px a
|
|
173
|
+
* seventh of the screen gives a cell, and held on one line it simply
|
|
174
|
+
* painted over its neighbours - seven prices overlapping into an
|
|
175
|
+
* unreadable smear, which is worse than the scroller this replaced. Two
|
|
176
|
+
* short lines fit.
|
|
177
|
+
*/
|
|
178
|
+
white-space: normal;
|
|
179
|
+
overflow-wrap: anywhere;
|
|
180
|
+
line-height: 1.15;
|
|
181
|
+
font-size: calc(${ props => props.theme.size.xs } - 1px);
|
|
182
|
+
|
|
183
|
+
@media (min-width: 640px) {
|
|
184
|
+
white-space: nowrap;
|
|
185
|
+
font-size: ${ props => props.theme.size.xs };
|
|
186
|
+
}
|
|
187
|
+
font-weight: ${ props => props.$cheapest ? 700 : 400 };
|
|
188
|
+
opacity: .85;
|
|
189
|
+
`;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
193
|
+
*
|
|
194
|
+
* Success green reads well against the dark strip, but the SELECTED day
|
|
195
|
+
* fills with the brand primary - green on that yellow measured about 1.5:1,
|
|
196
|
+
* unreadable at 10px. On the selected day it inherits the button's own
|
|
197
|
+
* contrast colour and leans on weight instead of hue.
|
|
198
|
+
*/
|
|
199
|
+
export const Cheapest = styled.span<{ $selected: boolean }>`
|
|
200
|
+
display: block;
|
|
201
|
+
font-size: ${ props => props.theme.size.xxs };
|
|
202
|
+
text-transform: uppercase;
|
|
203
|
+
letter-spacing: .5px;
|
|
204
|
+
font-weight: 700;
|
|
205
|
+
color: ${ props => props.$selected ? 'inherit' : props.theme.font.success };
|
|
206
|
+
`;
|
|
207
|
+
|
|
44
208
|
export const ContainerLoading = styled.div`
|
|
45
209
|
height: 26px;
|
|
46
210
|
text-align: center;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Container, Headline, Title, Lead, Group, GroupTitle, Options, Option, OptionName, OptionMeta, OptionPrice } from './styles';
|
|
3
|
+
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
4
|
+
import { AlternativesData, NearbyCity, FoundDay } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
type: 'departure' | '_return'
|
|
8
|
+
step1: RoutesSearchForm
|
|
9
|
+
data?: AlternativesData
|
|
10
|
+
loading: boolean
|
|
11
|
+
t: TFunction<'common'>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A search that found nothing, answered rather than apologised for.
|
|
16
|
+
*
|
|
17
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
18
|
+
*
|
|
19
|
+
* "We couldn't find any tickets" is where people leave. Every suggestion
|
|
20
|
+
* here is a real search that obtapi has already confirmed returns seats on
|
|
21
|
+
* the requested date, with the fare it would cost - so a click is never a
|
|
22
|
+
* second dead end.
|
|
23
|
+
*
|
|
24
|
+
* Nearby cities are ordered by actual distance from the city that was
|
|
25
|
+
* asked for, which is why a 29km alternative is offered before a 73km one
|
|
26
|
+
* even when the further city is cheaper: the passenger's problem here is
|
|
27
|
+
* getting to the bus, not saving two euros.
|
|
28
|
+
*/
|
|
29
|
+
const Empty = ({ type, step1, data, loading, t }: Props): JSX.Element => {
|
|
30
|
+
// obtapi wants DD/MM/YYYY, the URL carries DD-MM-YYYY
|
|
31
|
+
const asPath = (date: string): string => date.replace(/\//g, '-');
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
* Edited: Claude - Date: 2026-08-28 (sweep, low)
|
|
35
|
+
*
|
|
36
|
+
* The suggestions here are now leg-aware. The URL always carries the
|
|
37
|
+
* OUTBOUND pair and both dates, but on the return leg the search that
|
|
38
|
+
* came back empty is destination->origin on the return date - so a
|
|
39
|
+
* suggested day must land in the RETURN slot (the old link moved the
|
|
40
|
+
* departure the buyer never asked to change and led straight back into
|
|
41
|
+
* the same empty step), and a nearby city for the return's origin is a
|
|
42
|
+
* replacement for the URL's `to`, not its `from`.
|
|
43
|
+
*/
|
|
44
|
+
const isReturn = type === '_return';
|
|
45
|
+
|
|
46
|
+
const link = (from: string, to: string, departure: string, _return: string | undefined): string => ([
|
|
47
|
+
'/bus-lines',
|
|
48
|
+
from,
|
|
49
|
+
to,
|
|
50
|
+
asPath(departure),
|
|
51
|
+
_return ? asPath(_return) : 'none',
|
|
52
|
+
step1.type ?? 'departure',
|
|
53
|
+
String(step1.adults),
|
|
54
|
+
String(step1.children),
|
|
55
|
+
String(step1.babies)
|
|
56
|
+
].join('/'));
|
|
57
|
+
|
|
58
|
+
const cityLink = (item: NearbyCity, side: 'from' | 'to'): string => {
|
|
59
|
+
// `side` is relative to the leg that was searched; map it back to the
|
|
60
|
+
// URL's outbound pair
|
|
61
|
+
if (side === 'from') {
|
|
62
|
+
return isReturn
|
|
63
|
+
? link(step1.from, item.city.slug, step1.departure, step1._return)
|
|
64
|
+
: link(item.city.slug, step1.to, step1.departure, step1._return);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return isReturn
|
|
68
|
+
? link(item.city.slug, step1.to, step1.departure, step1._return)
|
|
69
|
+
: link(step1.from, item.city.slug, step1.departure, step1._return);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const city = (item: NearbyCity, side: 'from' | 'to'): JSX.Element => (
|
|
73
|
+
<Option key={ item.city.id } to={ cityLink(item, side) }>
|
|
74
|
+
<OptionName title={ item.city.name }>{ item.city.name }</OptionName>
|
|
75
|
+
<OptionMeta>{ t('routes_order.step2.empty.distance', { km: item.distance }) }</OptionMeta>
|
|
76
|
+
<OptionPrice>{ item.from_price_display }</OptionPrice>
|
|
77
|
+
</Option>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const day = (item: FoundDay): JSX.Element => (
|
|
81
|
+
<Option
|
|
82
|
+
key={ item.id }
|
|
83
|
+
to={ isReturn
|
|
84
|
+
? link(step1.from, step1.to, step1.departure, item.day)
|
|
85
|
+
: link(step1.from, step1.to, item.day, step1._return) }
|
|
86
|
+
>
|
|
87
|
+
<OptionName>{ item.day }</OptionName>
|
|
88
|
+
<OptionMeta>{ t('routes_order.step2.empty.available') }</OptionMeta>
|
|
89
|
+
{ item.from_price_display && <OptionPrice>{ item.from_price_display }</OptionPrice> }
|
|
90
|
+
</Option>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const hasFrom = (data?.from.length ?? 0) > 0;
|
|
94
|
+
const hasTo = (data?.to.length ?? 0) > 0;
|
|
95
|
+
const hasDates = (data?.dates.length ?? 0) > 0;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Container className="box">
|
|
99
|
+
<Headline>
|
|
100
|
+
<Title>{ t('routes_order.step2.empty.title') }</Title>
|
|
101
|
+
|
|
102
|
+
{ /* while the alternatives are still being fetched, say nothing
|
|
103
|
+
about them - promising suggestions that then turn out not to
|
|
104
|
+
exist is worse than the plain message */ }
|
|
105
|
+
<Lead>
|
|
106
|
+
{ loading || hasFrom || hasTo || hasDates
|
|
107
|
+
? t('routes_order.step2.empty.lead')
|
|
108
|
+
: t('routes_order.step2.empty.lead_none') }
|
|
109
|
+
</Lead>
|
|
110
|
+
</Headline>
|
|
111
|
+
|
|
112
|
+
{ hasDates && (
|
|
113
|
+
<Group>
|
|
114
|
+
<GroupTitle>{ t('routes_order.step2.empty.dates') }</GroupTitle>
|
|
115
|
+
<Options>{ data?.dates.map(day) }</Options>
|
|
116
|
+
</Group>
|
|
117
|
+
) }
|
|
118
|
+
|
|
119
|
+
{ hasFrom && (
|
|
120
|
+
<Group>
|
|
121
|
+
<GroupTitle>{ t('routes_order.step2.empty.from') }</GroupTitle>
|
|
122
|
+
<Options>{ data?.from.map(item => city(item, 'from')) }</Options>
|
|
123
|
+
</Group>
|
|
124
|
+
) }
|
|
125
|
+
|
|
126
|
+
{ hasTo && (
|
|
127
|
+
<Group>
|
|
128
|
+
<GroupTitle>{ t('routes_order.step2.empty.to') }</GroupTitle>
|
|
129
|
+
<Options>{ data?.to.map(item => city(item, 'to')) }</Options>
|
|
130
|
+
</Group>
|
|
131
|
+
) }
|
|
132
|
+
</Container>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export default Empty;
|
|
@@ -0,0 +1,98 @@
|
|
|
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-05
|
|
48
|
+
*
|
|
49
|
+
* Plain client-side links now. They used to force a full document reload,
|
|
50
|
+
* because the search lived on a Step1 that is not mounted once results are
|
|
51
|
+
* on screen - so a navigation changed the address bar and nothing else, and
|
|
52
|
+
* the old empty result set simply stayed there.
|
|
53
|
+
*
|
|
54
|
+
* RoutesOrder now follows the URL's search spec, which is the tidier fix
|
|
55
|
+
* the note here previously judged not worth the risk. It became worth it
|
|
56
|
+
* when the same root cause turned up a third time.
|
|
57
|
+
*/
|
|
58
|
+
export const Option = styled(Link)`
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: 3px;
|
|
62
|
+
padding: 12px 15px;
|
|
63
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
64
|
+
border: 1px solid ${ props => props.theme.inputs.border };
|
|
65
|
+
background: ${ props => props.theme.background.normal };
|
|
66
|
+
transition: all 0.2s ease;
|
|
67
|
+
|
|
68
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
69
|
+
// These are cards, not sentences, so they lift and take the brand colour
|
|
70
|
+
// on hover rather than underlining. The global anchor rule underlines on
|
|
71
|
+
// hover, hence the explicit none.
|
|
72
|
+
&:hover {
|
|
73
|
+
text-decoration: none;
|
|
74
|
+
transform: translateY(-2px);
|
|
75
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
76
|
+
box-shadow: ${ props => props.theme.boxShadow };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
&:hover strong {
|
|
80
|
+
color: ${ props => props.theme.primary.normal };
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
export const OptionName = styled.strong`
|
|
85
|
+
overflow: hidden;
|
|
86
|
+
text-overflow: ellipsis;
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
export const OptionMeta = styled.span`
|
|
91
|
+
font-size: ${ props => props.theme.size.xs };
|
|
92
|
+
color: ${ props => props.theme.font.faded };
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
export const OptionPrice = styled.span`
|
|
96
|
+
font-weight: 700;
|
|
97
|
+
font-size: ${ props => props.theme.size.s };
|
|
98
|
+
`;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { FaFilter } from 'react-icons/fa';
|
|
3
|
+
import { AiOutlineCaretDown, AiOutlineCaretUp } from 'react-icons/ai';
|
|
4
|
+
import { Container, Toggle, Count, Panel, Group, GroupTitle, Choice, ChoiceName, ChoiceMeta, ChoiceCount, ChoicePrice, Slider, Reset } from './styles';
|
|
5
|
+
import { Refinement, Facets, FacetValue, BucketKey, EMPTY, isRefined } from '../refine';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
open: boolean
|
|
9
|
+
refinement: Refinement
|
|
10
|
+
facets: Facets
|
|
11
|
+
range: { min: number, max: number } | null
|
|
12
|
+
currency: string
|
|
13
|
+
t: TFunction<'common'>
|
|
14
|
+
onToggle: () => void
|
|
15
|
+
onChange: (refinement: Refinement) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ListFacet = 'operators' | 'fromStops' | 'toStops' | 'features';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Faceted filters for the results list.
|
|
22
|
+
*
|
|
23
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
24
|
+
*
|
|
25
|
+
* Collapsed by default and rendered above the list rather than in a
|
|
26
|
+
* sidebar: the result cards are already a full-width layout that works on a
|
|
27
|
+
* phone, and carving a column out of it would have cost more than the
|
|
28
|
+
* filters are worth on the screen size most of our traffic arrives on.
|
|
29
|
+
*
|
|
30
|
+
* Counts come from `buildFacets`, which recomputes each facet against the
|
|
31
|
+
* OTHER active filters - so ticking one operator doesn't zero out every
|
|
32
|
+
* other operator's count and strand the user in a dead end.
|
|
33
|
+
*/
|
|
34
|
+
const Filters = ({ open, refinement, facets, range, currency, t, onToggle, onChange }: Props): JSX.Element => {
|
|
35
|
+
const active = [
|
|
36
|
+
refinement.operators.length,
|
|
37
|
+
refinement.buckets.length,
|
|
38
|
+
refinement.fromStops.length,
|
|
39
|
+
refinement.toStops.length,
|
|
40
|
+
refinement.features.length,
|
|
41
|
+
refinement.direct ? 1 : 0,
|
|
42
|
+
refinement.maxPrice === null ? 0 : 1
|
|
43
|
+
].reduce((total, value) => total + value, 0);
|
|
44
|
+
|
|
45
|
+
const onList = (facet: ListFacet, value: number): void => {
|
|
46
|
+
const current = refinement[facet];
|
|
47
|
+
|
|
48
|
+
onChange({
|
|
49
|
+
...refinement,
|
|
50
|
+
[facet]: current.includes(value) ? current.filter(item => item !== value) : [ ...current, value ]
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const onBucket = (value: BucketKey): void => {
|
|
55
|
+
onChange({
|
|
56
|
+
...refinement,
|
|
57
|
+
buckets: refinement.buckets.includes(value)
|
|
58
|
+
? refinement.buckets.filter(item => item !== value)
|
|
59
|
+
: [ ...refinement.buckets, value ]
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const list = (facet: ListFacet, title: string, values: FacetValue[]): JSX.Element | null => {
|
|
64
|
+
// a facet with a single value cannot narrow anything - showing it is
|
|
65
|
+
// just a checkbox that does nothing
|
|
66
|
+
if (values.length < 2) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Group>
|
|
72
|
+
<GroupTitle>{ title }</GroupTitle>
|
|
73
|
+
|
|
74
|
+
{ values.map(item => (
|
|
75
|
+
<Choice key={ item.value } $disabled={ item.count === 0 }>
|
|
76
|
+
<input
|
|
77
|
+
type="checkbox"
|
|
78
|
+
checked={ refinement[facet].includes(item.value) }
|
|
79
|
+
disabled={ item.count === 0 && !refinement[facet].includes(item.value) }
|
|
80
|
+
onChange={ () => onList(facet, item.value) }
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<ChoiceName title={ item.label }>{ item.label }</ChoiceName>
|
|
84
|
+
|
|
85
|
+
<ChoiceMeta>
|
|
86
|
+
<ChoiceCount>{ item.count }</ChoiceCount>
|
|
87
|
+
{ item.fromDisplay && <ChoicePrice>{ item.fromDisplay }</ChoicePrice> }
|
|
88
|
+
</ChoiceMeta>
|
|
89
|
+
</Choice>
|
|
90
|
+
)) }
|
|
91
|
+
</Group>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Container>
|
|
97
|
+
<Toggle type="button" $open={ open } aria-expanded={ open } onClick={ onToggle }>
|
|
98
|
+
<FaFilter />
|
|
99
|
+
{ t('routes_order.step2.filters.title') }
|
|
100
|
+
{ active > 0 && <Count>{ active }</Count> }
|
|
101
|
+
{ open ? <AiOutlineCaretUp /> : <AiOutlineCaretDown /> }
|
|
102
|
+
</Toggle>
|
|
103
|
+
|
|
104
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
105
|
+
Rendered unconditionally and hidden with CSS rather than gated on
|
|
106
|
+
`open`, because at sidebar widths there is no toggle to open it. */ }
|
|
107
|
+
<Panel className="box" $open={ open }>
|
|
108
|
+
{ facets.buckets.length > 1 && (
|
|
109
|
+
<Group>
|
|
110
|
+
<GroupTitle>{ t('routes_order.step2.filters.times') }</GroupTitle>
|
|
111
|
+
|
|
112
|
+
{ facets.buckets.map(bucket => (
|
|
113
|
+
<Choice key={ bucket.value } $disabled={ bucket.count === 0 }>
|
|
114
|
+
<input
|
|
115
|
+
type="checkbox"
|
|
116
|
+
checked={ refinement.buckets.includes(String(bucket.value) as BucketKey) }
|
|
117
|
+
disabled={ bucket.count === 0 && !refinement.buckets.includes(String(bucket.value) as BucketKey) }
|
|
118
|
+
onChange={ () => onBucket(String(bucket.value) as BucketKey) }
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
<ChoiceName>{ bucket.label }</ChoiceName>
|
|
122
|
+
|
|
123
|
+
<ChoiceMeta>
|
|
124
|
+
<ChoiceCount>{ bucket.count }</ChoiceCount>
|
|
125
|
+
{ bucket.fromDisplay && <ChoicePrice>{ bucket.fromDisplay }</ChoicePrice> }
|
|
126
|
+
</ChoiceMeta>
|
|
127
|
+
</Choice>
|
|
128
|
+
)) }
|
|
129
|
+
</Group>
|
|
130
|
+
) }
|
|
131
|
+
|
|
132
|
+
{ list('operators', t('routes_order.step2.filters.operators'), facets.operators) }
|
|
133
|
+
|
|
134
|
+
{ facets.hasDirect && (
|
|
135
|
+
<Group>
|
|
136
|
+
<GroupTitle>{ t('routes_order.step2.filters.stops') }</GroupTitle>
|
|
137
|
+
|
|
138
|
+
<Choice $disabled={ facets.direct === 0 && !refinement.direct }>
|
|
139
|
+
<input
|
|
140
|
+
type="checkbox"
|
|
141
|
+
checked={ refinement.direct }
|
|
142
|
+
disabled={ facets.direct === 0 && !refinement.direct }
|
|
143
|
+
onChange={ () => onChange({ ...refinement, direct: !refinement.direct }) }
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
<ChoiceName>{ t('routes_order.step2.filters.direct') }</ChoiceName>
|
|
147
|
+
|
|
148
|
+
<ChoiceMeta>{ facets.direct }</ChoiceMeta>
|
|
149
|
+
</Choice>
|
|
150
|
+
</Group>
|
|
151
|
+
) }
|
|
152
|
+
|
|
153
|
+
{ list('fromStops', t('routes_order.step2.filters.from_stops'), facets.fromStops) }
|
|
154
|
+
{ list('toStops', t('routes_order.step2.filters.to_stops'), facets.toStops) }
|
|
155
|
+
{ list('features', t('routes_order.step2.filters.features'), facets.features) }
|
|
156
|
+
|
|
157
|
+
{ range !== null && range.max > range.min && (
|
|
158
|
+
<Group>
|
|
159
|
+
<GroupTitle>{ t('routes_order.step2.filters.max_price') }</GroupTitle>
|
|
160
|
+
|
|
161
|
+
<ChoiceMeta>
|
|
162
|
+
{ (refinement.maxPrice ?? range.max).toFixed(2) } { currency }
|
|
163
|
+
</ChoiceMeta>
|
|
164
|
+
|
|
165
|
+
<Slider
|
|
166
|
+
type="range"
|
|
167
|
+
min={ Math.floor(range.min) }
|
|
168
|
+
max={ Math.ceil(range.max) }
|
|
169
|
+
step={ 1 }
|
|
170
|
+
value={ refinement.maxPrice ?? Math.ceil(range.max) }
|
|
171
|
+
onChange={ event => {
|
|
172
|
+
const value = Number(event.target.value);
|
|
173
|
+
|
|
174
|
+
// dragging back to the top is the same as no filter at all,
|
|
175
|
+
// so it clears rather than pinning a max nobody can exceed
|
|
176
|
+
onChange({ ...refinement, maxPrice: value >= Math.ceil(range.max) ? null : value });
|
|
177
|
+
} }
|
|
178
|
+
/>
|
|
179
|
+
</Group>
|
|
180
|
+
) }
|
|
181
|
+
|
|
182
|
+
{ isRefined(refinement) && (
|
|
183
|
+
<Group>
|
|
184
|
+
<Reset type="button" onClick={ () => onChange({ ...EMPTY, sort: refinement.sort }) }>
|
|
185
|
+
{ t('routes_order.step2.filters.reset') }
|
|
186
|
+
</Reset>
|
|
187
|
+
</Group>
|
|
188
|
+
) }
|
|
189
|
+
</Panel>
|
|
190
|
+
</Container>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export default Filters;
|