@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 +58 -0
- package/Found/Dates/Dates.tsx +38 -2
- package/Found/Dates/styles.ts +37 -3
- package/Found/Filters/Filters.tsx +191 -0
- package/Found/Filters/styles.ts +117 -0
- package/Found/Found.tsx +107 -16
- package/Found/Group/Group.tsx +66 -0
- package/Found/Group/styles.ts +44 -0
- package/Found/Orders/Orders.tsx +67 -26
- package/Found/Orders/styles.ts +14 -0
- package/Found/Picks/Picks.tsx +68 -0
- package/Found/Picks/styles.ts +55 -0
- package/Found/Route/Route.tsx +24 -2
- package/Found/Route/styles.ts +7 -0
- package/Found/Sort/Sort.tsx +40 -0
- package/Found/Sort/styles.ts +42 -0
- package/Found/Summary/Summary.tsx +49 -0
- package/Found/Summary/styles.ts +30 -0
- package/Found/refine.ts +583 -0
- package/package.json +1 -1
- package/services.ts +7 -1
- package/types.ts +6 -0
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
|
package/Found/Dates/Dates.tsx
CHANGED
|
@@ -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) }>
|
|
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
|
|
package/Found/Dates/styles.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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:
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|