@autobusal/routes-order 1.8.0 → 1.9.0

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 CHANGED
@@ -3,6 +3,64 @@
3
3
  All notable changes to this package are documented here. This project follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
5
5
 
6
+ > Note: 1.8.0 was published without a changelog entry. The gap is left as-is
7
+ > rather than reconstructed after the fact.
8
+
9
+ ## [1.9.0] - 2026-08-01
10
+
11
+ ### Added
12
+
13
+ - **Sorting on the results list** - recommended, cheapest, fastest,
14
+ earliest, latest. Preferred-stop pinning stays the outer partition and
15
+ each side sorts within itself, so choosing "cheapest" can no longer
16
+ silently un-pin a stop the user saved. Every sort carries a tiebreak on
17
+ the other axis, so three trips tied on journey time are led by the
18
+ cheapest of them rather than by whichever happened to come first.
19
+ - **"Recommended" is defined and merit-first**: price rank plus duration
20
+ rank across the current result set, with the operator's paid subscription
21
+ priority breaking ties only between trips already equal on both. Paid
22
+ placement cannot overtake a cheaper or faster competitor. This is a
23
+ deliberate commercial decision (Ferjolt, 2026-08-01) and it is the answer
24
+ if anyone asks what the word means on this page. It replaces the previous
25
+ default, which was obtapi's departure-time order - that view is still one
26
+ click away as "earliest".
27
+ - **Quick-pick cards** for cheapest / fastest / recommended, each naming
28
+ the winning fare, journey time and operator, and applying that ordering
29
+ on click. Computed from the filtered list, so a card never advertises a
30
+ trip the user's own filters have hidden.
31
+ - **Faceted filters with live counts and a from-price per value** -
32
+ departure-time bucket, operator, direct-only, departure stop, arrival
33
+ stop, on-board amenities, and a maximum price. Counts recompute against
34
+ the *other* active filters, and values that would return nothing render
35
+ disabled rather than vanishing mid-interaction.
36
+ - **Per-day prices on the date strip**, from obtapi's `from_price` /
37
+ `from_price_display`. The cheapest day is badged only when the cheap days
38
+ are a genuine minority - badging six days out of seven trains people to
39
+ ignore the badge.
40
+ - **Result count and price range** above the list, which also makes it
41
+ visible when a filter is hiding results.
42
+ - **Collapsed duplicate departures.** Trips from the same operator between
43
+ the same two stops at the same price and duration render as one card with
44
+ a departure-time picker instead of a wall of near-identical rows - the
45
+ shape hourly Albanian intercity service produces. Whatever time is picked
46
+ is what gets booked.
47
+ - **Price clarity line** under every fare. It states the party size rather
48
+ than "per adult", because `price.value` is the total for the whole
49
+ passenger mix, not a per-person fare.
50
+
51
+ The refinement lives in the URL, so a filtered result set survives a reload
52
+ and can be shared. External-provider offers are never dropped by a facet
53
+ they cannot report (stops, amenities) - a filter that deletes paid partner
54
+ inventory is a revenue bug, not a UI detail.
55
+
56
+ ### Fixed
57
+
58
+ - **`useGetDates` cache key ignored the search.** It was
59
+ `['step2-dates', { number }]`, so every city pair and passenger mix shared
60
+ one cache entry and the strip could show a previous trip's days. Harmless
61
+ while the strip held only booleans; with per-day prices on it, it would
62
+ have quoted one route's fares under another route's dates.
63
+
6
64
  ## [1.7.0] - 2026-07-30
7
65
 
8
66
  ### Added
@@ -4,7 +4,7 @@ import { AiOutlineCaretLeft, AiOutlineCaretRight } from 'react-icons/ai';
4
4
  import { Button } from '@autobusal/common';
5
5
  import { createDate, getFormattedShort } from '@autobusal/utilities';
6
6
  import Loading from './Loading';
7
- import { Container, Inner, ButtonDay } from './styles';
7
+ import { Container, Inner, ButtonDay, DayPrice, Cheapest } from './styles';
8
8
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
9
9
  import { FoundDay } from '../../types';
10
10
  import { useGetDates } from '../../services';
@@ -43,14 +43,50 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
43
43
 
44
44
  const today = new Date();
45
45
 
46
+ /**
47
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
48
+ *
49
+ * The cheapest fare in the visible window, so it can be called out.
50
+ *
51
+ * Two guards, both learned by looking at it: the badge is pointless when
52
+ * every day costs the same, and it is worse than pointless when most days
53
+ * tie at the minimum - badging six of seven days trains people to ignore
54
+ * the badge entirely. It only appears when the cheap days are genuinely
55
+ * the minority, which is when shifting a date is actually worth telling
56
+ * somebody about.
57
+ */
58
+ const prices = (data ?? [])
59
+ .filter(item => typeof item.from_price === 'number')
60
+ .map(item => item.from_price as number);
61
+
62
+ const lowest = prices.length > 0 ? Math.min(...prices) : null;
63
+
64
+ const atLowest = prices.filter(price => price === lowest).length;
65
+
66
+ const varies = prices.length > 1
67
+ && Math.max(...prices) > (lowest as number)
68
+ && atLowest * 2 <= prices.length;
69
+
46
70
  const items = data?.map(item => {
47
71
  const date = createDate(item.day);
48
72
 
49
73
  // if date in the future, and we have routes available
50
74
  const isAvailable = today.getTime() <= date.getTime() && item.routes;
51
75
 
76
+ const isCheapest = varies && lowest !== null && item.from_price === lowest;
77
+
52
78
  return (
53
- <ButtonDay key={ item.id } $available={ isAvailable } $selected={ step1[type] === item.day } onClick={ () => onClick(item, isAvailable) }>{ getFormattedShort(date, t) }</ButtonDay>
79
+ <ButtonDay key={ item.id } $available={ isAvailable } $selected={ step1[type] === item.day } onClick={ () => onClick(item, isAvailable) }>
80
+ { getFormattedShort(date, t) }
81
+
82
+ { isAvailable && item.from_price_display && (
83
+ <DayPrice $cheapest={ isCheapest }>{ item.from_price_display }</DayPrice>
84
+ ) }
85
+
86
+ { isCheapest && isAvailable && (
87
+ <Cheapest $selected={ step1[type] === item.day }>{ t('routes_order.step2.dates.cheapest') }</Cheapest>
88
+ ) }
89
+ </ButtonDay>
54
90
  );
55
91
  })
56
92
 
@@ -18,12 +18,17 @@ export const Inner = styled.div`
18
18
 
19
19
  export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean }>`
20
20
  flex: 1;
21
- height: 26px;
22
- line-height: 26px;
21
+ display: flex;
22
+ flex-direction: column;
23
+ align-items: center;
24
+ justify-content: center;
25
+ gap: 2px;
26
+ padding: 4px 0;
27
+ min-height: 26px;
23
28
  font-weight: 700;
24
29
  text-align: center;
25
30
  font-size: ${ props => props.theme.size.s };
26
- border-radius: 100px;
31
+ border-radius: 12px;
27
32
  transition: all 0.3s ease;
28
33
 
29
34
  ${ props => !props.$available && css`
@@ -41,6 +46,35 @@ export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean
41
46
  ` }
42
47
  `;
43
48
 
49
+ /**
50
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
51
+ *
52
+ * Inherits colour from the selected/hovered day rather than setting its own,
53
+ * so the price stays readable once the parent flips to the primary fill.
54
+ */
55
+ export const DayPrice = styled.span<{ $cheapest: boolean }>`
56
+ font-size: ${ props => props.theme.size.xs };
57
+ font-weight: ${ props => props.$cheapest ? 700 : 400 };
58
+ opacity: .85;
59
+ `;
60
+
61
+ /**
62
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
63
+ *
64
+ * Success green reads well against the dark strip, but the SELECTED day
65
+ * fills with the brand primary - green on that yellow measured about 1.5:1,
66
+ * unreadable at 10px. On the selected day it inherits the button's own
67
+ * contrast colour and leans on weight instead of hue.
68
+ */
69
+ export const Cheapest = styled.span<{ $selected: boolean }>`
70
+ display: block;
71
+ font-size: ${ props => props.theme.size.xxs };
72
+ text-transform: uppercase;
73
+ letter-spacing: .5px;
74
+ font-weight: 700;
75
+ color: ${ props => props.$selected ? 'inherit' : props.theme.font.success };
76
+ `;
77
+
44
78
  export const ContainerLoading = styled.div`
45
79
  height: 26px;
46
80
  text-align: center;
@@ -0,0 +1,191 @@
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, 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
+ { item.count }{ item.fromDisplay ? ` · ${ item.fromDisplay }` : '' }
87
+ </ChoiceMeta>
88
+ </Choice>
89
+ )) }
90
+ </Group>
91
+ );
92
+ };
93
+
94
+ return (
95
+ <Container>
96
+ <Toggle type="button" $open={ open } aria-expanded={ open } onClick={ onToggle }>
97
+ <FaFilter />
98
+ { t('routes_order.step2.filters.title') }
99
+ { active > 0 && <Count>{ active }</Count> }
100
+ { open ? <AiOutlineCaretUp /> : <AiOutlineCaretDown /> }
101
+ </Toggle>
102
+
103
+ { open && (
104
+ <Panel className="box">
105
+ { facets.buckets.length > 1 && (
106
+ <Group>
107
+ <GroupTitle>{ t('routes_order.step2.filters.times') }</GroupTitle>
108
+
109
+ { facets.buckets.map(bucket => (
110
+ <Choice key={ bucket.value } $disabled={ bucket.count === 0 }>
111
+ <input
112
+ type="checkbox"
113
+ checked={ refinement.buckets.includes(String(bucket.value) as BucketKey) }
114
+ disabled={ bucket.count === 0 && !refinement.buckets.includes(String(bucket.value) as BucketKey) }
115
+ onChange={ () => onBucket(String(bucket.value) as BucketKey) }
116
+ />
117
+
118
+ <ChoiceName>{ bucket.label }</ChoiceName>
119
+
120
+ <ChoiceMeta>
121
+ { bucket.count }{ bucket.fromDisplay ? ` · ${ bucket.fromDisplay }` : '' }
122
+ </ChoiceMeta>
123
+ </Choice>
124
+ )) }
125
+ </Group>
126
+ ) }
127
+
128
+ { list('operators', t('routes_order.step2.filters.operators'), facets.operators) }
129
+
130
+ { facets.hasDirect && (
131
+ <Group>
132
+ <GroupTitle>{ t('routes_order.step2.filters.stops') }</GroupTitle>
133
+
134
+ <Choice $disabled={ facets.direct === 0 && !refinement.direct }>
135
+ <input
136
+ type="checkbox"
137
+ checked={ refinement.direct }
138
+ disabled={ facets.direct === 0 && !refinement.direct }
139
+ onChange={ () => onChange({ ...refinement, direct: !refinement.direct }) }
140
+ />
141
+
142
+ <ChoiceName>{ t('routes_order.step2.filters.direct') }</ChoiceName>
143
+
144
+ <ChoiceMeta>{ facets.direct }</ChoiceMeta>
145
+ </Choice>
146
+ </Group>
147
+ ) }
148
+
149
+ { list('fromStops', t('routes_order.step2.filters.from_stops'), facets.fromStops) }
150
+ { list('toStops', t('routes_order.step2.filters.to_stops'), facets.toStops) }
151
+ { list('features', t('routes_order.step2.filters.features'), facets.features) }
152
+
153
+ { range !== null && range.max > range.min && (
154
+ <Group>
155
+ <GroupTitle>{ t('routes_order.step2.filters.max_price') }</GroupTitle>
156
+
157
+ <ChoiceMeta>
158
+ { (refinement.maxPrice ?? range.max).toFixed(2) } { currency }
159
+ </ChoiceMeta>
160
+
161
+ <Slider
162
+ type="range"
163
+ min={ Math.floor(range.min) }
164
+ max={ Math.ceil(range.max) }
165
+ step={ 1 }
166
+ value={ refinement.maxPrice ?? Math.ceil(range.max) }
167
+ onChange={ event => {
168
+ const value = Number(event.target.value);
169
+
170
+ // dragging back to the top is the same as no filter at all,
171
+ // so it clears rather than pinning a max nobody can exceed
172
+ onChange({ ...refinement, maxPrice: value >= Math.ceil(range.max) ? null : value });
173
+ } }
174
+ />
175
+ </Group>
176
+ ) }
177
+
178
+ { isRefined(refinement) && (
179
+ <Group>
180
+ <Reset type="button" onClick={ () => onChange({ ...EMPTY, sort: refinement.sort }) }>
181
+ { t('routes_order.step2.filters.reset') }
182
+ </Reset>
183
+ </Group>
184
+ ) }
185
+ </Panel>
186
+ ) }
187
+ </Container>
188
+ );
189
+ };
190
+
191
+ export default Filters;
@@ -0,0 +1,117 @@
1
+ import styled, { css } from 'styled-components';
2
+
3
+ export const Container = styled.div`
4
+ margin-bottom: 15px;
5
+ `;
6
+
7
+ export const Toggle = styled.button<{ $open: boolean }>`
8
+ display: flex;
9
+ align-items: center;
10
+ gap: 8px;
11
+ padding: 8px 16px;
12
+ font-size: ${ props => props.theme.size.s };
13
+ font-weight: 700;
14
+ border-radius: 100px;
15
+ border: 1px solid ${ props => props.theme.inputs.border };
16
+ background: ${ props => props.theme.background.normal };
17
+ color: ${ props => props.theme.font.normal };
18
+ transition: all 0.2s ease;
19
+
20
+ &:hover {
21
+ border-color: ${ props => props.theme.primary.normal };
22
+ }
23
+
24
+ ${ props => props.$open && css`
25
+ border-color: ${ props => props.theme.primary.normal };
26
+ ` }
27
+ `;
28
+
29
+ export const Count = styled.span`
30
+ min-width: 20px;
31
+ padding: 1px 7px;
32
+ font-size: ${ props => props.theme.size.xs };
33
+ color: ${ props => props.theme.primary.contrast };
34
+ background: ${ props => props.theme.primary.normal };
35
+ border-radius: 100px;
36
+ `;
37
+
38
+ export const Panel = styled.div`
39
+ display: grid;
40
+ gap: 20px;
41
+ margin-top: 12px;
42
+ padding: 20px;
43
+
44
+ @media (min-width: 640px) {
45
+ grid-template-columns: repeat(2, 1fr);
46
+ }
47
+
48
+ @media (min-width: 1024px) {
49
+ grid-template-columns: repeat(3, 1fr);
50
+ }
51
+ `;
52
+
53
+ export const Group = styled.div`
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 8px;
57
+ `;
58
+
59
+ export const GroupTitle = styled.h6`
60
+ margin-bottom: 0;
61
+ font-size: ${ props => props.theme.size.xs };
62
+ text-transform: uppercase;
63
+ color: ${ props => props.theme.font.faded };
64
+ `;
65
+
66
+ export const Choice = styled.label<{ $disabled: boolean }>`
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 8px;
70
+ font-size: ${ props => props.theme.size.s };
71
+ cursor: pointer;
72
+
73
+ & > input {
74
+ cursor: pointer;
75
+ accent-color: ${ props => props.theme.primary.normal };
76
+ }
77
+
78
+ ${ props => props.$disabled && css`
79
+ cursor: default;
80
+ opacity: .45;
81
+
82
+ & > input {
83
+ cursor: default;
84
+ }
85
+ ` }
86
+ `;
87
+
88
+ export const ChoiceName = styled.span`
89
+ flex: 1;
90
+ overflow: hidden;
91
+ text-overflow: ellipsis;
92
+ white-space: nowrap;
93
+ `;
94
+
95
+ export const ChoiceMeta = styled.span`
96
+ font-size: ${ props => props.theme.size.xs };
97
+ color: ${ props => props.theme.font.faded };
98
+ white-space: nowrap;
99
+ `;
100
+
101
+ export const Slider = styled.input`
102
+ width: 100%;
103
+ accent-color: ${ props => props.theme.primary.normal };
104
+ cursor: pointer;
105
+ `;
106
+
107
+ export const Reset = styled.button`
108
+ align-self: flex-start;
109
+ font-size: ${ props => props.theme.size.s };
110
+ font-weight: 700;
111
+ color: ${ props => props.theme.font.error };
112
+ transition: all 0.2s ease;
113
+
114
+ &:hover {
115
+ opacity: .8;
116
+ }
117
+ `;
package/Found/Found.tsx CHANGED
@@ -1,10 +1,17 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
1
3
  import { TFunction } from 'i18next';
2
4
  import Dates from './Dates/Dates';
3
5
  import Booking from './Booking/Booking';
4
6
  import Header from './Header/Header';
5
7
  import Orders from './Orders/Orders';
8
+ import Sort from './Sort/Sort';
9
+ import Picks from './Picks/Picks';
10
+ import Filters from './Filters/Filters';
11
+ import Summary from './Summary/Summary';
6
12
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
7
13
  import { FoundData } from '@autobusal/providers/types/routes';
14
+ import { Refinement, BucketKey, fromParams, toParams, filterItems, buildFacets, priceRange, currencyOf, isRefined } from './refine';
8
15
 
9
16
  interface Props {
10
17
  type: 'departure' | '_return'
@@ -17,23 +24,107 @@ interface Props {
17
24
  onSave: (data: FoundData) => void
18
25
  }
19
26
 
20
- const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave }: Props): JSX.Element => (
21
- <>
22
- <Dates type={ type } step1={ step1 } t={ t } onSearch={ onSearch } />
27
+ // every query parameter this component owns, so writing a new refinement
28
+ // clears the previous one without touching anything else on the URL
29
+ const OWNED = [ 'sort', 'operators', 'times', 'direct', 'from_stops', 'to_stops', 'features', 'max_price' ];
23
30
 
24
- { (data !== undefined && data.length > 0) && <Booking data={ data } step1={ step1 } t={ t } /> }
31
+ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave }: Props): JSX.Element => {
32
+ const [ searchParams, setSearchParams ] = useSearchParams();
33
+ const [ open, setOpen ] = useState<boolean>(false);
25
34
 
26
- <Header t={ t } />
35
+ /**
36
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
37
+ *
38
+ * The refinement lives in the URL, not in component state, so a filtered
39
+ * result set survives a reload and can be sent to somebody else - which is
40
+ * how people actually share a trip they found.
41
+ */
42
+ const refinement = useMemo(() => fromParams(searchParams), [ searchParams ]);
27
43
 
28
- <Orders
29
- loading={ loading }
30
- data={ data }
31
- type={ type }
32
- preferredStop={ Number(preferredStop) }
33
- t={ t }
34
- onSave={ onSave }
35
- />
36
- </>
37
- );
44
+ const items = useMemo(() => data ?? [], [ data ]);
38
45
 
39
- export default Found;
46
+ const bucketLabel = (key: BucketKey): string => t(`routes_order.step2.filters.buckets.${ key }`);
47
+
48
+ const facets = useMemo(() => buildFacets(items, refinement, bucketLabel), [ items, refinement, t ]);
49
+
50
+ const refined = useMemo(() => filterItems(items, refinement), [ items, refinement ]);
51
+
52
+ const range = useMemo(() => priceRange(refined.length > 0 ? refined : items), [ refined, items ]);
53
+
54
+ // the slider's bounds must come from the WHOLE result set, not the
55
+ // filtered one - bounds that shrink as you drag make it impossible to
56
+ // widen the range again
57
+ const bounds = useMemo(() => priceRange(items), [ items ]);
58
+
59
+ const currency = useMemo(() => currencyOf(items), [ items ]);
60
+
61
+ const onRefine = (next: Refinement): void => {
62
+ const params = new URLSearchParams(searchParams);
63
+
64
+ OWNED.forEach(name => params.delete(name));
65
+
66
+ Object.entries(toParams(next)).forEach(([ name, value ]) => params.set(name, value));
67
+
68
+ // `replace` so a back press returns to the search form rather than
69
+ // walking back through every checkbox the user just ticked
70
+ setSearchParams(params, { replace: true });
71
+ };
72
+
73
+ const passengers = Number(step1.adults ?? 0) + Number(step1.children ?? 0) + Number(step1.babies ?? 0);
74
+
75
+ // controls are pointless while there is nothing (or only one thing) to
76
+ // refine, and they'd only add noise above an empty state
77
+ const showControls = !loading && items.length > 1;
78
+
79
+ return (
80
+ <>
81
+ <Dates type={ type } step1={ step1 } t={ t } onSearch={ onSearch } />
82
+
83
+ { (data !== undefined && data.length > 0) && <Booking data={ data } step1={ step1 } t={ t } /> }
84
+
85
+ { showControls && (
86
+ <>
87
+ <Summary shown={ refined.length } total={ items.length } range={ range } t={ t }>
88
+ <Sort value={ refinement.sort } t={ t } onChange={ sort => onRefine({ ...refinement, sort }) } />
89
+ </Summary>
90
+
91
+ <Filters
92
+ open={ open }
93
+ refinement={ refinement }
94
+ facets={ facets }
95
+ range={ bounds }
96
+ currency={ currency }
97
+ t={ t }
98
+ onToggle={ () => setOpen(!open) }
99
+ onChange={ onRefine }
100
+ />
101
+
102
+ <Picks
103
+ items={ refined }
104
+ sort={ refinement.sort }
105
+ t={ t }
106
+ onSelect={ sort => onRefine({ ...refinement, sort }) }
107
+ />
108
+ </>
109
+ ) }
110
+
111
+ <Header t={ t } />
112
+
113
+ <Orders
114
+ loading={ loading }
115
+ data={ refined }
116
+ empty={ items.length === 0 }
117
+ refined={ isRefined(refinement) }
118
+ sort={ refinement.sort }
119
+ type={ type }
120
+ passengers={ passengers }
121
+ preferredStop={ Number(preferredStop) }
122
+ t={ t }
123
+ onReset={ () => onRefine({ ...refinement, ...{ operators: [], buckets: [], direct: false, fromStops: [], toStops: [], features: [], maxPrice: null } }) }
124
+ onSave={ onSave }
125
+ />
126
+ </>
127
+ );
128
+ };
129
+
130
+ export default Found;