@autobusal/routes-order 1.8.0 → 1.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/Found/Dates/Dates.tsx +38 -2
- package/Found/Dates/styles.ts +37 -3
- package/Found/Filters/Filters.tsx +194 -0
- package/Found/Filters/styles.ts +188 -0
- package/Found/Found.tsx +116 -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 +46 -7
- package/Found/Route/styles.ts +55 -8
- 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 +42 -0
- package/Found/refine.ts +626 -0
- package/package.json +1 -1
- package/services.ts +7 -1
- package/styles.ts +36 -0
- package/types.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,118 @@
|
|
|
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.3] - 2026-08-01
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Trip length reads as a duration, not a clock time.** "08:30" sat
|
|
14
|
+
directly between two real departure times with nothing to say it meant
|
|
15
|
+
eight and a half hours rather than half past eight. It now renders as
|
|
16
|
+
`07h 30m` behind an hourglass, and an exact number of hours drops the
|
|
17
|
+
minutes (`08h`, not `08h 00m`). The unit labels come from the translation
|
|
18
|
+
files - "h"/"m" are a Latin-script convention, so Greek reads `07ω 30λ`,
|
|
19
|
+
Bulgarian and Macedonian `07ч 30м`, Albanian `07o 30m`.
|
|
20
|
+
obtapi's `-` sentinel (no computable duration) still passes straight
|
|
21
|
+
through rather than becoming a confident "0h 0m".
|
|
22
|
+
- **Stop name down to 12px** on the result cards.
|
|
23
|
+
|
|
24
|
+
## [1.9.2] - 2026-08-01
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **Stop name stepped down to 14px** on the result cards. At the same 16px
|
|
29
|
+
as body copy it competed with the city name above it; the city should be
|
|
30
|
+
read first and the stop only when it is needed.
|
|
31
|
+
- **Country code renders upper case and smaller.** It arrives from the
|
|
32
|
+
database lower case ("alb", "grc") - an ISO country code is upper case by
|
|
33
|
+
definition, so it is cased at render time rather than left to the data,
|
|
34
|
+
where every consumer would have to remember.
|
|
35
|
+
|
|
36
|
+
## [1.9.1] - 2026-08-01
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **Filters became a sidebar** at 1280px and up - always visible, sticky
|
|
41
|
+
while the list scrolls, no click needed to discover them. Below 1280px
|
|
42
|
+
they stay the collapsible panel above the list. The breakpoint is 1280
|
|
43
|
+
rather than 1024 because the result cards switch to their wide row layout
|
|
44
|
+
at 1024px on VIEWPORT width, not container width, so taking a column out
|
|
45
|
+
of a 1024px window would leave the cards using a layout their remaining
|
|
46
|
+
space cannot carry.
|
|
47
|
+
- **Facet counts are a filled pill**, separate from the from-price. They
|
|
48
|
+
used to read as one muted string ("5 · 38.00 EUR") where the two numbers
|
|
49
|
+
ran together and it was not obvious which was which.
|
|
50
|
+
- **Facet labels wrap instead of truncating.** In a sidebar column an
|
|
51
|
+
ellipsis reduced two different stops at the same city to the same
|
|
52
|
+
"Terminali Lindor Inte..." - the one distinction the filter exists to
|
|
53
|
+
make.
|
|
54
|
+
- **The result-count / sort bar sits on the card surface** rather than
|
|
55
|
+
floating on the page background.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- Filter panel grid tracks used `1fr`, whose implicit min-width is `auto`,
|
|
60
|
+
so a long stop name pushed the whole panel wider than its column and
|
|
61
|
+
pushed the counts and prices out of view entirely. Now `minmax(0, 1fr)`.
|
|
62
|
+
|
|
63
|
+
## [1.9.0] - 2026-08-01
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **Sorting on the results list** - recommended, cheapest, fastest,
|
|
68
|
+
earliest, latest. Preferred-stop pinning stays the outer partition and
|
|
69
|
+
each side sorts within itself, so choosing "cheapest" can no longer
|
|
70
|
+
silently un-pin a stop the user saved. Every sort carries a tiebreak on
|
|
71
|
+
the other axis, so three trips tied on journey time are led by the
|
|
72
|
+
cheapest of them rather than by whichever happened to come first.
|
|
73
|
+
- **"Recommended" is defined and merit-first**: price rank plus duration
|
|
74
|
+
rank across the current result set, with the operator's paid subscription
|
|
75
|
+
priority breaking ties only between trips already equal on both. Paid
|
|
76
|
+
placement cannot overtake a cheaper or faster competitor. This is a
|
|
77
|
+
deliberate commercial decision (Ferjolt, 2026-08-01) and it is the answer
|
|
78
|
+
if anyone asks what the word means on this page. It replaces the previous
|
|
79
|
+
default, which was obtapi's departure-time order - that view is still one
|
|
80
|
+
click away as "earliest".
|
|
81
|
+
- **Quick-pick cards** for cheapest / fastest / recommended, each naming
|
|
82
|
+
the winning fare, journey time and operator, and applying that ordering
|
|
83
|
+
on click. Computed from the filtered list, so a card never advertises a
|
|
84
|
+
trip the user's own filters have hidden.
|
|
85
|
+
- **Faceted filters with live counts and a from-price per value** -
|
|
86
|
+
departure-time bucket, operator, direct-only, departure stop, arrival
|
|
87
|
+
stop, on-board amenities, and a maximum price. Counts recompute against
|
|
88
|
+
the *other* active filters, and values that would return nothing render
|
|
89
|
+
disabled rather than vanishing mid-interaction.
|
|
90
|
+
- **Per-day prices on the date strip**, from obtapi's `from_price` /
|
|
91
|
+
`from_price_display`. The cheapest day is badged only when the cheap days
|
|
92
|
+
are a genuine minority - badging six days out of seven trains people to
|
|
93
|
+
ignore the badge.
|
|
94
|
+
- **Result count and price range** above the list, which also makes it
|
|
95
|
+
visible when a filter is hiding results.
|
|
96
|
+
- **Collapsed duplicate departures.** Trips from the same operator between
|
|
97
|
+
the same two stops at the same price and duration render as one card with
|
|
98
|
+
a departure-time picker instead of a wall of near-identical rows - the
|
|
99
|
+
shape hourly Albanian intercity service produces. Whatever time is picked
|
|
100
|
+
is what gets booked.
|
|
101
|
+
- **Price clarity line** under every fare. It states the party size rather
|
|
102
|
+
than "per adult", because `price.value` is the total for the whole
|
|
103
|
+
passenger mix, not a per-person fare.
|
|
104
|
+
|
|
105
|
+
The refinement lives in the URL, so a filtered result set survives a reload
|
|
106
|
+
and can be shared. External-provider offers are never dropped by a facet
|
|
107
|
+
they cannot report (stops, amenities) - a filter that deletes paid partner
|
|
108
|
+
inventory is a revenue bug, not a UI detail.
|
|
109
|
+
|
|
110
|
+
### Fixed
|
|
111
|
+
|
|
112
|
+
- **`useGetDates` cache key ignored the search.** It was
|
|
113
|
+
`['step2-dates', { number }]`, so every city pair and passenger mix shared
|
|
114
|
+
one cache entry and the strip could show a previous trip's days. Harmless
|
|
115
|
+
while the strip held only booleans; with per-day prices on it, it would
|
|
116
|
+
have quoted one route's fares under another route's dates.
|
|
117
|
+
|
|
6
118
|
## [1.7.0] - 2026-07-30
|
|
7
119
|
|
|
8
120
|
### 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,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;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
5
|
+
*
|
|
6
|
+
* Below 1280px the filters stay a collapsible panel above the list; at and
|
|
7
|
+
* above it they become a permanent sidebar. 1280 rather than 1024 on
|
|
8
|
+
* purpose: the result CARDS switch to their wide row layout at 1024px based
|
|
9
|
+
* on the viewport, not on their container, so carving a 280px column out of
|
|
10
|
+
* a 1024px window would leave the cards using a layout their remaining
|
|
11
|
+
* ~700px cannot carry.
|
|
12
|
+
*/
|
|
13
|
+
export const Container = styled.div`
|
|
14
|
+
margin-bottom: 15px;
|
|
15
|
+
|
|
16
|
+
@media (min-width: 1280px) {
|
|
17
|
+
margin-bottom: 0;
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export const Toggle = styled.button<{ $open: boolean }>`
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: 8px;
|
|
25
|
+
padding: 8px 16px;
|
|
26
|
+
font-size: ${ props => props.theme.size.s };
|
|
27
|
+
font-weight: 700;
|
|
28
|
+
border-radius: 100px;
|
|
29
|
+
border: 1px solid ${ props => props.theme.inputs.border };
|
|
30
|
+
background: ${ props => props.theme.background.normal };
|
|
31
|
+
color: ${ props => props.theme.font.normal };
|
|
32
|
+
transition: all 0.2s ease;
|
|
33
|
+
|
|
34
|
+
&:hover {
|
|
35
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
${ props => props.$open && css`
|
|
39
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
40
|
+
` }
|
|
41
|
+
|
|
42
|
+
// the sidebar is always open, so there is nothing left to toggle
|
|
43
|
+
@media (min-width: 1280px) {
|
|
44
|
+
display: none;
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
export const Count = styled.span`
|
|
49
|
+
min-width: 20px;
|
|
50
|
+
padding: 1px 7px;
|
|
51
|
+
font-size: ${ props => props.theme.size.xs };
|
|
52
|
+
color: ${ props => props.theme.primary.contrast };
|
|
53
|
+
background: ${ props => props.theme.primary.normal };
|
|
54
|
+
border-radius: 100px;
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
export const Panel = styled.div<{ $open: boolean }>`
|
|
58
|
+
display: ${ props => props.$open ? 'grid' : 'none' };
|
|
59
|
+
gap: 20px;
|
|
60
|
+
margin-top: 12px;
|
|
61
|
+
padding: 20px;
|
|
62
|
+
// minmax(0, 1fr) rather than 1fr: a grid track's implicit min-width is
|
|
63
|
+
// auto, so a long stop name like "Terminali Lindor Interurban dhe
|
|
64
|
+
// Nderkombetar i Autobusave (TEG)" pushed the whole panel wider than its
|
|
65
|
+
// column and pushed the counts and prices out of sight
|
|
66
|
+
grid-template-columns: minmax(0, 1fr);
|
|
67
|
+
|
|
68
|
+
@media (min-width: 640px) {
|
|
69
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@media (min-width: 1024px) {
|
|
73
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// as a sidebar: always rendered, one column, and it follows the page down
|
|
77
|
+
// so the filters stay reachable while scrolling a long result list
|
|
78
|
+
@media (min-width: 1280px) {
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-columns: minmax(0, 1fr);
|
|
81
|
+
margin-top: 0;
|
|
82
|
+
position: sticky;
|
|
83
|
+
top: 15px;
|
|
84
|
+
max-height: calc(100vh - 30px);
|
|
85
|
+
overflow-y: auto;
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
export const Group = styled.div`
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
gap: 8px;
|
|
93
|
+
min-width: 0;
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
export const GroupTitle = styled.h6`
|
|
97
|
+
margin-bottom: 0;
|
|
98
|
+
font-size: ${ props => props.theme.size.xs };
|
|
99
|
+
text-transform: uppercase;
|
|
100
|
+
color: ${ props => props.theme.font.faded };
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
export const Choice = styled.label<{ $disabled: boolean }>`
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: flex-start;
|
|
106
|
+
gap: 8px;
|
|
107
|
+
line-height: 1.35;
|
|
108
|
+
font-size: ${ props => props.theme.size.s };
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
|
|
111
|
+
& > input {
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
accent-color: ${ props => props.theme.primary.normal };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
${ props => props.$disabled && css`
|
|
117
|
+
cursor: default;
|
|
118
|
+
opacity: .45;
|
|
119
|
+
|
|
120
|
+
& > input {
|
|
121
|
+
cursor: default;
|
|
122
|
+
}
|
|
123
|
+
` }
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
128
|
+
*
|
|
129
|
+
* Wraps rather than truncating. In the sidebar column an ellipsis turned
|
|
130
|
+
* "Terminali Lindor Interurban dhe Nderkombetar i Autobusave (TEG)" into
|
|
131
|
+
* "Terminali Lindor Inte..." - two stops at the same city are then
|
|
132
|
+
* indistinguishable, which is the one thing this filter exists to tell
|
|
133
|
+
* apart. A second line is cheaper than a useless label.
|
|
134
|
+
*/
|
|
135
|
+
export const ChoiceName = styled.span`
|
|
136
|
+
flex: 1;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
overflow-wrap: anywhere;
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
export const ChoiceMeta = styled.span`
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
gap: 6px;
|
|
145
|
+
font-size: ${ props => props.theme.size.xs };
|
|
146
|
+
color: ${ props => props.theme.font.faded };
|
|
147
|
+
white-space: nowrap;
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
152
|
+
*
|
|
153
|
+
* How many trips a facet value leaves, as a filled pill. It used to sit
|
|
154
|
+
* next to the price as "5 · 38.00 EUR" in one muted string, where the two
|
|
155
|
+
* numbers read as one and you had to work out which was which. A count and
|
|
156
|
+
* a fare are different kinds of thing and now look like it.
|
|
157
|
+
*/
|
|
158
|
+
export const ChoiceCount = styled.span`
|
|
159
|
+
min-width: 22px;
|
|
160
|
+
padding: 1px 6px;
|
|
161
|
+
text-align: center;
|
|
162
|
+
font-weight: 700;
|
|
163
|
+
color: ${ props => props.theme.font.normal };
|
|
164
|
+
background: ${ props => props.theme.background.neutral };
|
|
165
|
+
border-radius: 100px;
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
export const ChoicePrice = styled.span`
|
|
169
|
+
color: ${ props => props.theme.font.faded };
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
export const Slider = styled.input`
|
|
173
|
+
width: 100%;
|
|
174
|
+
accent-color: ${ props => props.theme.primary.normal };
|
|
175
|
+
cursor: pointer;
|
|
176
|
+
`;
|
|
177
|
+
|
|
178
|
+
export const Reset = styled.button`
|
|
179
|
+
align-self: flex-start;
|
|
180
|
+
font-size: ${ props => props.theme.size.s };
|
|
181
|
+
font-weight: 700;
|
|
182
|
+
color: ${ props => props.theme.font.error };
|
|
183
|
+
transition: all 0.2s ease;
|
|
184
|
+
|
|
185
|
+
&:hover {
|
|
186
|
+
opacity: .8;
|
|
187
|
+
}
|
|
188
|
+
`;
|