@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 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
@@ -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,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
+ `;