@autobusal/routes-order 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +731 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +95 -5
  14. package/Found/Dates/styles.ts +173 -9
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +194 -0
  18. package/Found/Filters/styles.ts +188 -0
  19. package/Found/Found.tsx +207 -17
  20. package/Found/Group/Group.tsx +68 -0
  21. package/Found/Group/styles.ts +44 -0
  22. package/Found/Header/styles.ts +20 -4
  23. package/Found/Orders/Orders.tsx +89 -27
  24. package/Found/Orders/styles.ts +14 -0
  25. package/Found/Picks/Picks.tsx +75 -0
  26. package/Found/Picks/styles.ts +106 -0
  27. package/Found/Route/Route.tsx +211 -33
  28. package/Found/Route/styles.ts +491 -46
  29. package/Found/SameDay/SameDay.tsx +35 -0
  30. package/Found/SameDay/styles.ts +27 -0
  31. package/Found/Sort/Sort.tsx +40 -0
  32. package/Found/Sort/styles.ts +87 -0
  33. package/Found/Summary/Summary.tsx +49 -0
  34. package/Found/Summary/styles.ts +42 -0
  35. package/Found/Watch/Watch.tsx +187 -0
  36. package/Found/Watch/styles.ts +118 -0
  37. package/Found/refine.ts +652 -0
  38. package/Links/Links.tsx +91 -0
  39. package/Links/styles.ts +75 -0
  40. package/RoutesOrder.tsx +239 -31
  41. package/Schedule/Schedule.tsx +123 -0
  42. package/Schedule/styles.ts +71 -0
  43. package/Sections/Schema.tsx +186 -0
  44. package/Sections/Sections.tsx +160 -0
  45. package/Sections/styles.ts +44 -0
  46. package/Step1/prepare.ts +21 -2
  47. package/Step2.tsx +34 -1
  48. package/Step4/Passenger/Passenger.tsx +253 -24
  49. package/Step4/Passenger/styles.ts +21 -1
  50. package/Step4/Passengers/Display.tsx +10 -2
  51. package/Step4/Passengers/Passengers.tsx +53 -4
  52. package/Step4/Saved/Choose.tsx +99 -0
  53. package/Step4/Saved/Remember.tsx +58 -0
  54. package/Step4/Saved/styles.ts +87 -0
  55. package/Step4/checkAge.ts +15 -4
  56. package/Step5/Addons/Addons.tsx +146 -4
  57. package/Step5/Addons/styles.ts +94 -0
  58. package/Step5/Billing/Billing.tsx +138 -70
  59. package/Step5/Coupons/Coupons.tsx +28 -18
  60. package/Step5/Coupons/styles.ts +41 -8
  61. package/Step5/Payments/Payments.tsx +36 -5
  62. package/Step5/Payments/styles.ts +16 -7
  63. package/Step5/Summary/About/About.tsx +99 -27
  64. package/Step5/Summary/About/styles.ts +118 -1
  65. package/Step5/Summary/styles.ts +12 -5
  66. package/Step5/utilities.ts +67 -12
  67. package/Steps/Steps.tsx +3 -1
  68. package/commerce.ts +24 -0
  69. package/package.json +1 -1
  70. package/services.ts +302 -13
  71. package/styles.ts +126 -0
  72. package/types.ts +84 -1
  73. package/Found/Booking/Booking.tsx +0 -61
  74. package/Found/Booking/styles.ts +0 -50
  75. package/Step4/Step4.tsx +0 -114
  76. package/Step5/Step5.tsx +0 -185
@@ -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
+ `;
package/Found/Found.tsx CHANGED
@@ -1,10 +1,22 @@
1
+ import { useMemo, useState, useEffect } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
1
3
  import { TFunction } from 'i18next';
2
4
  import Dates from './Dates/Dates';
3
- import Booking from './Booking/Booking';
4
5
  import Header from './Header/Header';
5
6
  import Orders from './Orders/Orders';
7
+ import Sort from './Sort/Sort';
8
+ import Picks from './Picks/Picks';
9
+ import Filters from './Filters/Filters';
10
+ import Summary from './Summary/Summary';
11
+ import SameDay from './SameDay/SameDay';
12
+ import Watch from './Watch/Watch';
13
+ import { Results, Sidebar, Listing } from '../styles';
14
+ import { useGetAlternatives } from '../services';
6
15
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
7
16
  import { FoundData } from '@autobusal/providers/types/routes';
17
+ import { viewItemList } from '@autobusal/providers/Setup/commerce';
18
+ import { item as commerceItem } from '../commerce';
19
+ import { Refinement, BucketKey, fromParams, toParams, filterItems, buildFacets, priceRange, currencyOf, isRefined } from './refine';
8
20
 
9
21
  interface Props {
10
22
  type: 'departure' | '_return'
@@ -17,23 +29,201 @@ interface Props {
17
29
  onSave: (data: FoundData) => void
18
30
  }
19
31
 
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 } />
32
+ // every query parameter this component owns, so writing a new refinement
33
+ // clears the previous one without touching anything else on the URL
34
+ const OWNED = [ 'sort', 'operators', 'times', 'direct', 'from_stops', 'to_stops', 'features', 'max_price' ];
23
35
 
24
- { (data !== undefined && data.length > 0) && <Booking data={ data } step1={ step1 } t={ t } /> }
36
+ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave }: Props): JSX.Element => {
37
+ /**
38
+ * Edited: Ferjolt Ozuni - Date: 2026-08-05
39
+ *
40
+ * Keyed on the RESULT SET, not on every render. This component re-renders
41
+ * on each filter tick and sort change, and announcing the list again every
42
+ * time would report a dozen searches for one.
43
+ */
44
+ useEffect(() => {
45
+ viewItemList((data ?? []).map(commerceItem), currencyOf(data ?? []));
46
+ }, [data]);
25
47
 
26
- <Header t={ t } />
48
+ const [ searchParams, setSearchParams ] = useSearchParams();
49
+ const [ open, setOpen ] = useState<boolean>(false);
27
50
 
28
- <Orders
29
- loading={ loading }
30
- data={ data }
31
- type={ type }
32
- preferredStop={ Number(preferredStop) }
33
- t={ t }
34
- onSave={ onSave }
35
- />
36
- </>
37
- );
51
+ /**
52
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
53
+ *
54
+ * The refinement lives in the URL, not in component state, so a filtered
55
+ * result set survives a reload and can be sent to somebody else - which is
56
+ * how people actually share a trip they found.
57
+ */
58
+ const refinement = useMemo(() => fromParams(searchParams), [ searchParams ]);
38
59
 
39
- export default Found;
60
+ const items = useMemo(() => data ?? [], [ data ]);
61
+
62
+ const bucketLabel = (key: BucketKey): string => t(`routes_order.step2.filters.buckets.${ key }`);
63
+
64
+ const facets = useMemo(() => buildFacets(items, refinement, bucketLabel), [ items, refinement, t ]);
65
+
66
+ const refined = useMemo(() => filterItems(items, refinement), [ items, refinement ]);
67
+
68
+ const range = useMemo(() => priceRange(refined.length > 0 ? refined : items), [ refined, items ]);
69
+
70
+ // the slider's bounds must come from the WHOLE result set, not the
71
+ // filtered one - bounds that shrink as you drag make it impossible to
72
+ // widen the range again
73
+ const bounds = useMemo(() => priceRange(items), [ items ]);
74
+
75
+ const currency = useMemo(() => currencyOf(items), [ items ]);
76
+
77
+ const onRefine = (next: Refinement): void => {
78
+ const params = new URLSearchParams(searchParams);
79
+
80
+ OWNED.forEach(name => params.delete(name));
81
+
82
+ Object.entries(toParams(next)).forEach(([ name, value ]) => params.set(name, value));
83
+
84
+ // `replace` so a back press returns to the search form rather than
85
+ // walking back through every checkbox the user just ticked
86
+ setSearchParams(params, { replace: true });
87
+ };
88
+
89
+ const passengers = Number(step1.adults ?? 0) + Number(step1.children ?? 0) + Number(step1.babies ?? 0);
90
+
91
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
92
+ // Only fetched once the search itself has finished and come back empty,
93
+ // so a search that found something never pays for the alternatives.
94
+ /*
95
+ * Edited: Claude - Date: 2026-08-28 (sweep, low)
96
+ *
97
+ * On the return leg the journey with no results is destination->origin
98
+ * on the RETURN date - the same swap useGetDates and `watched` below
99
+ * have always made. Passing step1 unswapped asked the API for OUTBOUND
100
+ * alternatives, so the empty return step suggested days for a leg the
101
+ * buyer already has (including the very day they booked).
102
+ */
103
+ const alternativesQuery = type === '_return'
104
+ ? { ...step1, from: step1.to, to: step1.from, departure: step1._return ?? '' }
105
+ : step1;
106
+
107
+ const { data: alternatives, isFetching: alternativesLoading } = useGetAlternatives(
108
+ alternativesQuery,
109
+ !loading && items.length === 0
110
+ );
111
+
112
+ // controls are pointless while there is nothing (or only one thing) to
113
+ // refine, and they'd only add noise above an empty state
114
+ const showControls = !loading && items.length > 1;
115
+
116
+ /**
117
+ * The journey the "watch this fare" control below is about.
118
+ *
119
+ * Edited: Claude - Date: 2026-08-20
120
+ *
121
+ * On the return leg the pair is the reverse of the one that was searched
122
+ * and the date is the return date - the same swap useGetDates has always
123
+ * made for the date strip, and for the same reason: step 3 is a search
124
+ * from the destination back, and watching the outbound fare there would
125
+ * watch a journey the visitor is no longer looking at.
126
+ */
127
+ const watched = type === '_return'
128
+ ? { from: step1.to, to: step1.from, departure: step1._return ?? '' }
129
+ : { from: step1.from, to: step1.to, departure: step1.departure };
130
+
131
+ return (
132
+ <>
133
+ <Dates type={ type } step1={ step1 } t={ t } onSearch={ onSearch } />
134
+
135
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
136
+ Keyed off obtapi sending a countdown at all, which it does only
137
+ for a same-day search - so there is no second copy of "is this
138
+ today" here to fall out of step with the server's. */ }
139
+ { items.some(item => item.departs_in !== null && item.departs_in !== undefined) && (
140
+ <SameDay t={ t } />
141
+ ) }
142
+
143
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
144
+ The Booking.com banner used to sit here, between the date strip
145
+ and the trips, pushing the actual results below it to advertise a
146
+ hotel to somebody still deciding whether they can travel at all.
147
+ It was unmounted earlier today and the component is now removed
148
+ outright, along with cities.booking_id - it was never really an
149
+ integration, just a hand-built searchresults.xu.html link, and
150
+ leaving the column and its admin field behind made it look like
151
+ one. */ }
152
+
153
+ { showControls && (
154
+ <>
155
+ <Summary shown={ refined.length } total={ items.length } range={ range } t={ t }>
156
+ <Sort value={ refinement.sort } t={ t } onChange={ sort => onRefine({ ...refinement, sort }) } />
157
+ </Summary>
158
+
159
+ <Picks
160
+ items={ refined }
161
+ sort={ refinement.sort }
162
+ t={ t }
163
+ onSelect={ sort => onRefine({ ...refinement, sort }) }
164
+ />
165
+ </>
166
+ ) }
167
+
168
+ <Results>
169
+ { showControls && (
170
+ <Sidebar>
171
+ <Filters
172
+ open={ open }
173
+ refinement={ refinement }
174
+ facets={ facets }
175
+ range={ bounds }
176
+ currency={ currency }
177
+ t={ t }
178
+ onToggle={ () => setOpen(!open) }
179
+ onChange={ onRefine }
180
+ />
181
+ </Sidebar>
182
+ ) }
183
+
184
+ <Listing>
185
+ <Header t={ t } />
186
+
187
+ <Orders
188
+ loading={ loading }
189
+ data={ refined }
190
+ empty={ items.length === 0 }
191
+ step1={ step1 }
192
+ alternatives={ alternatives }
193
+ alternativesLoading={ alternativesLoading }
194
+ refined={ isRefined(refinement) }
195
+ sort={ refinement.sort }
196
+ type={ type }
197
+ passengers={ passengers }
198
+ preferredStop={ Number(preferredStop) }
199
+ t={ t }
200
+ onReset={ () => onRefine({ ...refinement, ...{ operators: [], buckets: [], direct: false, fromStops: [], toStops: [], features: [], maxPrice: null } }) }
201
+ onSave={ onSave }
202
+ />
203
+ </Listing>
204
+ </Results>
205
+
206
+ { /* Edited: Claude - Date: 2026-08-20
207
+ At the FOOT of the results, and outside <Results> so it spans the
208
+ full width rather than sitting under the filter column.
209
+
210
+ This is the one spot on the screen that is reached in every
211
+ outcome a search can have - a list of prices none of which was
212
+ the one the reader wanted, a filter that hid them all, and a
213
+ search that found nothing at all. That last case is the one that
214
+ decided the placement: "no buses for this search" is precisely
215
+ when somebody wants telling if that changes, and a control living
216
+ inside the listing would have been absent exactly there.
217
+
218
+ Held back while the search is still running - offering to watch a
219
+ fare before we know whether there is one to watch, under a
220
+ spinner, is noise. A pair nobody prices answers 404 and says so.
221
+ */ }
222
+ { !loading && watched.departure !== '' && (
223
+ <Watch from={ watched.from } to={ watched.to } departure={ watched.departure } t={ t } />
224
+ ) }
225
+ </>
226
+ );
227
+ };
228
+
229
+ export default Found;
@@ -0,0 +1,68 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import Route from '../Route/Route';
4
+ import { departureOf } from '../refine';
5
+ import { Container, Title, Times, Time } from './styles';
6
+ import { FoundData } from '@autobusal/providers/types/routes';
7
+
8
+ interface Props {
9
+ items: FoundData[]
10
+ type: 'favorite' | 'normal'
11
+ date?: string
12
+ passengers: number
13
+ t: TFunction<'common'>
14
+ onSave: (data: FoundData) => void
15
+ }
16
+
17
+ /**
18
+ * One card for a set of interchangeable departures.
19
+ *
20
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
21
+ *
22
+ * The card shows a real departure - never an abstract summary - and the
23
+ * other times sit under it as a picker. Whatever is selected is what gets
24
+ * booked, so there is no separate "now choose a time" step to forget: the
25
+ * card always represents a bookable trip, starting with the earliest.
26
+ */
27
+ const Group = ({ items, type, date, passengers, t, onSave }: Props): JSX.Element => {
28
+ // chronological for the picker, regardless of how the list itself is
29
+ // sorted - a row of times out of time order is just confusing
30
+ const departures = useMemo(() => (
31
+ [ ...items ].sort((first, second) => departureOf(first) - departureOf(second))
32
+ ), [ items ]);
33
+
34
+ const [ selected, setSelected ] = useState<number>(0);
35
+
36
+ const current = departures[selected] ?? departures[0];
37
+
38
+ return (
39
+ <Route
40
+ type={ type }
41
+ data={ current }
42
+ date={ date }
43
+ passengers={ passengers }
44
+ t={ t }
45
+ onSave={ onSave }
46
+ >
47
+ <Container>
48
+ <Title>{ t('routes_order.step2.route.departures', { total: departures.length }) }</Title>
49
+
50
+ <Times>
51
+ { departures.map((item, index) => (
52
+ <Time
53
+ key={ item.id }
54
+ type="button"
55
+ $active={ index === selected }
56
+ aria-pressed={ index === selected }
57
+ onClick={ () => setSelected(index) }
58
+ >
59
+ { item.locations.from.departure }
60
+ </Time>
61
+ )) }
62
+ </Times>
63
+ </Container>
64
+ </Route>
65
+ );
66
+ };
67
+
68
+ export default Group;
@@ -0,0 +1,44 @@
1
+ import styled, { css } from 'styled-components';
2
+
3
+ export const Container = styled.div`
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: 8px;
7
+ padding: 12px;
8
+ background: ${ props => props.theme.background.neutral };
9
+ border-radius: ${ props => props.theme.borderRadius };
10
+ `;
11
+
12
+ export const Title = styled.h6`
13
+ margin-bottom: 0;
14
+ font-size: ${ props => props.theme.size.xs };
15
+ text-transform: uppercase;
16
+ color: ${ props => props.theme.font.faded };
17
+ `;
18
+
19
+ export const Times = styled.div`
20
+ display: flex;
21
+ flex-wrap: wrap;
22
+ gap: 6px;
23
+ `;
24
+
25
+ export const Time = styled.button<{ $active: boolean }>`
26
+ padding: 5px 12px;
27
+ font-size: ${ props => props.theme.size.s };
28
+ font-weight: 700;
29
+ border-radius: 100px;
30
+ border: 1px solid ${ props => props.theme.inputs.border };
31
+ background: ${ props => props.theme.background.normal };
32
+ color: ${ props => props.theme.font.normal };
33
+ transition: all 0.2s ease;
34
+
35
+ &:hover {
36
+ border-color: ${ props => props.theme.primary.normal };
37
+ }
38
+
39
+ ${ props => props.$active && css`
40
+ color: ${ props => props.theme.primary.contrast };
41
+ background: ${ props => props.theme.primary.normal };
42
+ border-color: ${ props => props.theme.primary.normal };
43
+ ` }
44
+ `;
@@ -1,8 +1,21 @@
1
1
  import styled from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
5
+ *
6
+ * This row only means anything if its labels sit over the columns they
7
+ * name, so its geometry has to match the result card's exactly: the same
8
+ * 15px horizontal padding and the same 15px gap between columns. It had
9
+ * 4px padding and no gap at all, which pushed every label right of the
10
+ * data it labelled - Duration was out by 34px.
11
+ *
12
+ * If Found/Route/styles.ts ever changes the card's padding or the Trip
13
+ * gap, this has to move with it.
14
+ */
3
15
  export const Container = styled.div`
4
16
  display: none;
5
- padding: 8px 4px;
17
+ gap: 15px;
18
+ padding: 8px 15px;
6
19
  margin-bottom: 15px;
7
20
 
8
21
  @media (min-width: 1024px) {
@@ -23,18 +36,21 @@ const Item = styled.div`
23
36
  }
24
37
  `;
25
38
 
39
+ // these five must stay identical to their counterparts in
40
+ // Found/Route/styles.ts, or the labels stop sitting over their columns
26
41
  export const Company = styled(Item)`
27
- flex-basis: 185px;
42
+ flex: 0 0 185px;
28
43
  `;
29
44
 
30
45
  export const Location = styled(Item)`
31
46
  flex: 1;
47
+ min-width: 0;
32
48
  `;
33
49
 
34
50
  export const Time = styled(Item)`
35
- flex-basis: 120px;
51
+ flex: 0 0 120px;
36
52
  `;
37
53
 
38
54
  export const Price = styled(Item)`
39
- flex-basis: 165px;
55
+ flex: 0 0 165px;
40
56
  `;