@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +731 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +95 -5
  14. package/Found/Dates/styles.ts +173 -9
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +194 -0
  18. package/Found/Filters/styles.ts +188 -0
  19. package/Found/Found.tsx +207 -17
  20. package/Found/Group/Group.tsx +68 -0
  21. package/Found/Group/styles.ts +44 -0
  22. package/Found/Header/styles.ts +20 -4
  23. package/Found/Orders/Orders.tsx +89 -27
  24. package/Found/Orders/styles.ts +14 -0
  25. package/Found/Picks/Picks.tsx +75 -0
  26. package/Found/Picks/styles.ts +106 -0
  27. package/Found/Route/Route.tsx +211 -33
  28. package/Found/Route/styles.ts +491 -46
  29. package/Found/SameDay/SameDay.tsx +35 -0
  30. package/Found/SameDay/styles.ts +27 -0
  31. package/Found/Sort/Sort.tsx +40 -0
  32. package/Found/Sort/styles.ts +87 -0
  33. package/Found/Summary/Summary.tsx +49 -0
  34. package/Found/Summary/styles.ts +42 -0
  35. package/Found/Watch/Watch.tsx +187 -0
  36. package/Found/Watch/styles.ts +118 -0
  37. package/Found/refine.ts +652 -0
  38. package/Links/Links.tsx +91 -0
  39. package/Links/styles.ts +75 -0
  40. package/RoutesOrder.tsx +239 -31
  41. package/Schedule/Schedule.tsx +123 -0
  42. package/Schedule/styles.ts +71 -0
  43. package/Sections/Schema.tsx +186 -0
  44. package/Sections/Sections.tsx +160 -0
  45. package/Sections/styles.ts +44 -0
  46. package/Step1/prepare.ts +21 -2
  47. package/Step2.tsx +34 -1
  48. package/Step4/Passenger/Passenger.tsx +253 -24
  49. package/Step4/Passenger/styles.ts +21 -1
  50. package/Step4/Passengers/Display.tsx +10 -2
  51. package/Step4/Passengers/Passengers.tsx +53 -4
  52. package/Step4/Saved/Choose.tsx +99 -0
  53. package/Step4/Saved/Remember.tsx +58 -0
  54. package/Step4/Saved/styles.ts +87 -0
  55. package/Step4/checkAge.ts +15 -4
  56. package/Step5/Addons/Addons.tsx +146 -4
  57. package/Step5/Addons/styles.ts +94 -0
  58. package/Step5/Billing/Billing.tsx +138 -70
  59. package/Step5/Coupons/Coupons.tsx +28 -18
  60. package/Step5/Coupons/styles.ts +41 -8
  61. package/Step5/Payments/Payments.tsx +36 -5
  62. package/Step5/Payments/styles.ts +16 -7
  63. package/Step5/Summary/About/About.tsx +99 -27
  64. package/Step5/Summary/About/styles.ts +118 -1
  65. package/Step5/Summary/styles.ts +12 -5
  66. package/Step5/utilities.ts +67 -12
  67. package/Steps/Steps.tsx +3 -1
  68. package/commerce.ts +24 -0
  69. package/package.json +1 -1
  70. package/services.ts +302 -13
  71. package/styles.ts +126 -0
  72. package/types.ts +84 -1
  73. package/Found/Booking/Booking.tsx +0 -61
  74. package/Found/Booking/styles.ts +0 -50
  75. package/Step4/Step4.tsx +0 -114
  76. package/Step5/Step5.tsx +0 -185
@@ -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: 10px 15px;
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: 5px;
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
- height: 26px;
22
- line-height: 26px;
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.s };
26
- border-radius: 100px;
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;