@autobusal/routes-order 1.9.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 (73) hide show
  1. package/CHANGELOG.md +673 -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 +59 -5
  14. package/Found/Dates/styles.ts +137 -7
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +10 -7
  18. package/Found/Filters/styles.ts +79 -8
  19. package/Found/Found.tsx +128 -29
  20. package/Found/Group/Group.tsx +3 -1
  21. package/Found/Header/styles.ts +20 -4
  22. package/Found/Orders/Orders.tsx +25 -4
  23. package/Found/Picks/Picks.tsx +10 -3
  24. package/Found/Picks/styles.ts +60 -9
  25. package/Found/Route/Route.tsx +189 -33
  26. package/Found/Route/styles.ts +488 -50
  27. package/Found/SameDay/SameDay.tsx +35 -0
  28. package/Found/SameDay/styles.ts +27 -0
  29. package/Found/Sort/styles.ts +52 -7
  30. package/Found/Summary/Summary.tsx +1 -1
  31. package/Found/Summary/styles.ts +12 -0
  32. package/Found/Watch/Watch.tsx +187 -0
  33. package/Found/Watch/styles.ts +118 -0
  34. package/Found/refine.ts +69 -0
  35. package/Links/Links.tsx +91 -0
  36. package/Links/styles.ts +75 -0
  37. package/RoutesOrder.tsx +239 -31
  38. package/Schedule/Schedule.tsx +123 -0
  39. package/Schedule/styles.ts +71 -0
  40. package/Sections/Schema.tsx +186 -0
  41. package/Sections/Sections.tsx +160 -0
  42. package/Sections/styles.ts +44 -0
  43. package/Step1/prepare.ts +21 -2
  44. package/Step2.tsx +34 -1
  45. package/Step4/Passenger/Passenger.tsx +253 -24
  46. package/Step4/Passenger/styles.ts +21 -1
  47. package/Step4/Passengers/Display.tsx +10 -2
  48. package/Step4/Passengers/Passengers.tsx +53 -4
  49. package/Step4/Saved/Choose.tsx +99 -0
  50. package/Step4/Saved/Remember.tsx +58 -0
  51. package/Step4/Saved/styles.ts +87 -0
  52. package/Step4/checkAge.ts +15 -4
  53. package/Step5/Addons/Addons.tsx +146 -4
  54. package/Step5/Addons/styles.ts +94 -0
  55. package/Step5/Billing/Billing.tsx +138 -70
  56. package/Step5/Coupons/Coupons.tsx +28 -18
  57. package/Step5/Coupons/styles.ts +41 -8
  58. package/Step5/Payments/Payments.tsx +36 -5
  59. package/Step5/Payments/styles.ts +16 -7
  60. package/Step5/Summary/About/About.tsx +99 -27
  61. package/Step5/Summary/About/styles.ts +118 -1
  62. package/Step5/Summary/styles.ts +12 -5
  63. package/Step5/utilities.ts +67 -12
  64. package/Steps/Steps.tsx +3 -1
  65. package/commerce.ts +24 -0
  66. package/package.json +1 -1
  67. package/services.ts +295 -12
  68. package/styles.ts +126 -0
  69. package/types.ts +78 -1
  70. package/Found/Booking/Booking.tsx +0 -61
  71. package/Found/Booking/styles.ts +0 -50
  72. package/Step4/Step4.tsx +0 -114
  73. package/Step5/Step5.tsx +0 -185
@@ -0,0 +1,136 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Container, Headline, Title, Lead, Group, GroupTitle, Options, Option, OptionName, OptionMeta, OptionPrice } from './styles';
3
+ import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
+ import { AlternativesData, NearbyCity, FoundDay } from '../../types';
5
+
6
+ interface Props {
7
+ type: 'departure' | '_return'
8
+ step1: RoutesSearchForm
9
+ data?: AlternativesData
10
+ loading: boolean
11
+ t: TFunction<'common'>
12
+ }
13
+
14
+ /**
15
+ * A search that found nothing, answered rather than apologised for.
16
+ *
17
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
18
+ *
19
+ * "We couldn't find any tickets" is where people leave. Every suggestion
20
+ * here is a real search that obtapi has already confirmed returns seats on
21
+ * the requested date, with the fare it would cost - so a click is never a
22
+ * second dead end.
23
+ *
24
+ * Nearby cities are ordered by actual distance from the city that was
25
+ * asked for, which is why a 29km alternative is offered before a 73km one
26
+ * even when the further city is cheaper: the passenger's problem here is
27
+ * getting to the bus, not saving two euros.
28
+ */
29
+ const Empty = ({ type, step1, data, loading, t }: Props): JSX.Element => {
30
+ // obtapi wants DD/MM/YYYY, the URL carries DD-MM-YYYY
31
+ const asPath = (date: string): string => date.replace(/\//g, '-');
32
+
33
+ /*
34
+ * Edited: Claude - Date: 2026-08-28 (sweep, low)
35
+ *
36
+ * The suggestions here are now leg-aware. The URL always carries the
37
+ * OUTBOUND pair and both dates, but on the return leg the search that
38
+ * came back empty is destination->origin on the return date - so a
39
+ * suggested day must land in the RETURN slot (the old link moved the
40
+ * departure the buyer never asked to change and led straight back into
41
+ * the same empty step), and a nearby city for the return's origin is a
42
+ * replacement for the URL's `to`, not its `from`.
43
+ */
44
+ const isReturn = type === '_return';
45
+
46
+ const link = (from: string, to: string, departure: string, _return: string | undefined): string => ([
47
+ '/bus-lines',
48
+ from,
49
+ to,
50
+ asPath(departure),
51
+ _return ? asPath(_return) : 'none',
52
+ step1.type ?? 'departure',
53
+ String(step1.adults),
54
+ String(step1.children),
55
+ String(step1.babies)
56
+ ].join('/'));
57
+
58
+ const cityLink = (item: NearbyCity, side: 'from' | 'to'): string => {
59
+ // `side` is relative to the leg that was searched; map it back to the
60
+ // URL's outbound pair
61
+ if (side === 'from') {
62
+ return isReturn
63
+ ? link(step1.from, item.city.slug, step1.departure, step1._return)
64
+ : link(item.city.slug, step1.to, step1.departure, step1._return);
65
+ }
66
+
67
+ return isReturn
68
+ ? link(item.city.slug, step1.to, step1.departure, step1._return)
69
+ : link(step1.from, item.city.slug, step1.departure, step1._return);
70
+ };
71
+
72
+ const city = (item: NearbyCity, side: 'from' | 'to'): JSX.Element => (
73
+ <Option key={ item.city.id } to={ cityLink(item, side) }>
74
+ <OptionName title={ item.city.name }>{ item.city.name }</OptionName>
75
+ <OptionMeta>{ t('routes_order.step2.empty.distance', { km: item.distance }) }</OptionMeta>
76
+ <OptionPrice>{ item.from_price_display }</OptionPrice>
77
+ </Option>
78
+ );
79
+
80
+ const day = (item: FoundDay): JSX.Element => (
81
+ <Option
82
+ key={ item.id }
83
+ to={ isReturn
84
+ ? link(step1.from, step1.to, step1.departure, item.day)
85
+ : link(step1.from, step1.to, item.day, step1._return) }
86
+ >
87
+ <OptionName>{ item.day }</OptionName>
88
+ <OptionMeta>{ t('routes_order.step2.empty.available') }</OptionMeta>
89
+ { item.from_price_display && <OptionPrice>{ item.from_price_display }</OptionPrice> }
90
+ </Option>
91
+ );
92
+
93
+ const hasFrom = (data?.from.length ?? 0) > 0;
94
+ const hasTo = (data?.to.length ?? 0) > 0;
95
+ const hasDates = (data?.dates.length ?? 0) > 0;
96
+
97
+ return (
98
+ <Container className="box">
99
+ <Headline>
100
+ <Title>{ t('routes_order.step2.empty.title') }</Title>
101
+
102
+ { /* while the alternatives are still being fetched, say nothing
103
+ about them - promising suggestions that then turn out not to
104
+ exist is worse than the plain message */ }
105
+ <Lead>
106
+ { loading || hasFrom || hasTo || hasDates
107
+ ? t('routes_order.step2.empty.lead')
108
+ : t('routes_order.step2.empty.lead_none') }
109
+ </Lead>
110
+ </Headline>
111
+
112
+ { hasDates && (
113
+ <Group>
114
+ <GroupTitle>{ t('routes_order.step2.empty.dates') }</GroupTitle>
115
+ <Options>{ data?.dates.map(day) }</Options>
116
+ </Group>
117
+ ) }
118
+
119
+ { hasFrom && (
120
+ <Group>
121
+ <GroupTitle>{ t('routes_order.step2.empty.from') }</GroupTitle>
122
+ <Options>{ data?.from.map(item => city(item, 'from')) }</Options>
123
+ </Group>
124
+ ) }
125
+
126
+ { hasTo && (
127
+ <Group>
128
+ <GroupTitle>{ t('routes_order.step2.empty.to') }</GroupTitle>
129
+ <Options>{ data?.to.map(item => city(item, 'to')) }</Options>
130
+ </Group>
131
+ ) }
132
+ </Container>
133
+ );
134
+ };
135
+
136
+ export default Empty;
@@ -0,0 +1,98 @@
1
+ import styled from 'styled-components';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ export const Container = styled.div`
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 20px;
8
+ `;
9
+
10
+ export const Headline = styled.div`
11
+ text-align: center;
12
+ `;
13
+
14
+ export const Title = styled.h3`
15
+ margin-bottom: 6px;
16
+ `;
17
+
18
+ export const Lead = styled.p`
19
+ margin: 0;
20
+ color: ${ props => props.theme.font.faded };
21
+ `;
22
+
23
+ export const Group = styled.div`
24
+ display: flex;
25
+ flex-direction: column;
26
+ gap: 10px;
27
+ `;
28
+
29
+ export const GroupTitle = styled.h6`
30
+ margin-bottom: 0;
31
+ font-size: ${ props => props.theme.size.xs };
32
+ text-transform: uppercase;
33
+ color: ${ props => props.theme.font.faded };
34
+ `;
35
+
36
+ export const Options = styled.div`
37
+ display: grid;
38
+ gap: 10px;
39
+ grid-template-columns: minmax(0, 1fr);
40
+
41
+ @media (min-width: 640px) {
42
+ grid-template-columns: repeat(3, minmax(0, 1fr));
43
+ }
44
+ `;
45
+
46
+ /**
47
+ * Edited: Ferjolt Ozuni - Date: 2026-08-05
48
+ *
49
+ * Plain client-side links now. They used to force a full document reload,
50
+ * because the search lived on a Step1 that is not mounted once results are
51
+ * on screen - so a navigation changed the address bar and nothing else, and
52
+ * the old empty result set simply stayed there.
53
+ *
54
+ * RoutesOrder now follows the URL's search spec, which is the tidier fix
55
+ * the note here previously judged not worth the risk. It became worth it
56
+ * when the same root cause turned up a third time.
57
+ */
58
+ export const Option = styled(Link)`
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: 3px;
62
+ padding: 12px 15px;
63
+ border-radius: ${ props => props.theme.borderRadius };
64
+ border: 1px solid ${ props => props.theme.inputs.border };
65
+ background: ${ props => props.theme.background.normal };
66
+ transition: all 0.2s ease;
67
+
68
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
69
+ // These are cards, not sentences, so they lift and take the brand colour
70
+ // on hover rather than underlining. The global anchor rule underlines on
71
+ // hover, hence the explicit none.
72
+ &:hover {
73
+ text-decoration: none;
74
+ transform: translateY(-2px);
75
+ border-color: ${ props => props.theme.primary.normal };
76
+ box-shadow: ${ props => props.theme.boxShadow };
77
+ }
78
+
79
+ &:hover strong {
80
+ color: ${ props => props.theme.primary.normal };
81
+ }
82
+ `;
83
+
84
+ export const OptionName = styled.strong`
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
87
+ white-space: nowrap;
88
+ `;
89
+
90
+ export const OptionMeta = styled.span`
91
+ font-size: ${ props => props.theme.size.xs };
92
+ color: ${ props => props.theme.font.faded };
93
+ `;
94
+
95
+ export const OptionPrice = styled.span`
96
+ font-weight: 700;
97
+ font-size: ${ props => props.theme.size.s };
98
+ `;
@@ -1,7 +1,7 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import { FaFilter } from 'react-icons/fa';
3
3
  import { AiOutlineCaretDown, AiOutlineCaretUp } from 'react-icons/ai';
4
- import { Container, Toggle, Count, Panel, Group, GroupTitle, Choice, ChoiceName, ChoiceMeta, Slider, Reset } from './styles';
4
+ import { Container, Toggle, Count, Panel, Group, GroupTitle, Choice, ChoiceName, ChoiceMeta, ChoiceCount, ChoicePrice, Slider, Reset } from './styles';
5
5
  import { Refinement, Facets, FacetValue, BucketKey, EMPTY, isRefined } from '../refine';
6
6
 
7
7
  interface Props {
@@ -83,7 +83,8 @@ const Filters = ({ open, refinement, facets, range, currency, t, onToggle, onCha
83
83
  <ChoiceName title={ item.label }>{ item.label }</ChoiceName>
84
84
 
85
85
  <ChoiceMeta>
86
- { item.count }{ item.fromDisplay ? ` · ${ item.fromDisplay }` : '' }
86
+ <ChoiceCount>{ item.count }</ChoiceCount>
87
+ { item.fromDisplay && <ChoicePrice>{ item.fromDisplay }</ChoicePrice> }
87
88
  </ChoiceMeta>
88
89
  </Choice>
89
90
  )) }
@@ -100,8 +101,10 @@ const Filters = ({ open, refinement, facets, range, currency, t, onToggle, onCha
100
101
  { open ? <AiOutlineCaretUp /> : <AiOutlineCaretDown /> }
101
102
  </Toggle>
102
103
 
103
- { open && (
104
- <Panel className="box">
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 }>
105
108
  { facets.buckets.length > 1 && (
106
109
  <Group>
107
110
  <GroupTitle>{ t('routes_order.step2.filters.times') }</GroupTitle>
@@ -118,7 +121,8 @@ const Filters = ({ open, refinement, facets, range, currency, t, onToggle, onCha
118
121
  <ChoiceName>{ bucket.label }</ChoiceName>
119
122
 
120
123
  <ChoiceMeta>
121
- { bucket.count }{ bucket.fromDisplay ? ` · ${ bucket.fromDisplay }` : '' }
124
+ <ChoiceCount>{ bucket.count }</ChoiceCount>
125
+ { bucket.fromDisplay && <ChoicePrice>{ bucket.fromDisplay }</ChoicePrice> }
122
126
  </ChoiceMeta>
123
127
  </Choice>
124
128
  )) }
@@ -182,8 +186,7 @@ const Filters = ({ open, refinement, facets, range, currency, t, onToggle, onCha
182
186
  </Reset>
183
187
  </Group>
184
188
  ) }
185
- </Panel>
186
- ) }
189
+ </Panel>
187
190
  </Container>
188
191
  );
189
192
  };
@@ -1,7 +1,21 @@
1
1
  import styled, { css } from 'styled-components';
2
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
+ */
3
13
  export const Container = styled.div`
4
14
  margin-bottom: 15px;
15
+
16
+ @media (min-width: 1280px) {
17
+ margin-bottom: 0;
18
+ }
5
19
  `;
6
20
 
7
21
  export const Toggle = styled.button<{ $open: boolean }>`
@@ -24,6 +38,11 @@ export const Toggle = styled.button<{ $open: boolean }>`
24
38
  ${ props => props.$open && css`
25
39
  border-color: ${ props => props.theme.primary.normal };
26
40
  ` }
41
+
42
+ // the sidebar is always open, so there is nothing left to toggle
43
+ @media (min-width: 1280px) {
44
+ display: none;
45
+ }
27
46
  `;
28
47
 
29
48
  export const Count = styled.span`
@@ -35,18 +54,35 @@ export const Count = styled.span`
35
54
  border-radius: 100px;
36
55
  `;
37
56
 
38
- export const Panel = styled.div`
39
- display: grid;
57
+ export const Panel = styled.div<{ $open: boolean }>`
58
+ display: ${ props => props.$open ? 'grid' : 'none' };
40
59
  gap: 20px;
41
60
  margin-top: 12px;
42
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);
43
67
 
44
68
  @media (min-width: 640px) {
45
- grid-template-columns: repeat(2, 1fr);
69
+ grid-template-columns: repeat(2, minmax(0, 1fr));
46
70
  }
47
71
 
48
72
  @media (min-width: 1024px) {
49
- grid-template-columns: repeat(3, 1fr);
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;
50
86
  }
51
87
  `;
52
88
 
@@ -54,6 +90,7 @@ export const Group = styled.div`
54
90
  display: flex;
55
91
  flex-direction: column;
56
92
  gap: 8px;
93
+ min-width: 0;
57
94
  `;
58
95
 
59
96
  export const GroupTitle = styled.h6`
@@ -65,8 +102,9 @@ export const GroupTitle = styled.h6`
65
102
 
66
103
  export const Choice = styled.label<{ $disabled: boolean }>`
67
104
  display: flex;
68
- align-items: center;
105
+ align-items: flex-start;
69
106
  gap: 8px;
107
+ line-height: 1.35;
70
108
  font-size: ${ props => props.theme.size.s };
71
109
  cursor: pointer;
72
110
 
@@ -85,19 +123,52 @@ export const Choice = styled.label<{ $disabled: boolean }>`
85
123
  ` }
86
124
  `;
87
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
+ */
88
135
  export const ChoiceName = styled.span`
89
136
  flex: 1;
90
- overflow: hidden;
91
- text-overflow: ellipsis;
92
- white-space: nowrap;
137
+ min-width: 0;
138
+ overflow-wrap: anywhere;
93
139
  `;
94
140
 
95
141
  export const ChoiceMeta = styled.span`
142
+ display: flex;
143
+ align-items: center;
144
+ gap: 6px;
96
145
  font-size: ${ props => props.theme.size.xs };
97
146
  color: ${ props => props.theme.font.faded };
98
147
  white-space: nowrap;
99
148
  `;
100
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
+
101
172
  export const Slider = styled.input`
102
173
  width: 100%;
103
174
  accent-color: ${ props => props.theme.primary.normal };
package/Found/Found.tsx CHANGED
@@ -1,16 +1,21 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useMemo, useState, useEffect } from 'react';
2
2
  import { useSearchParams } from 'react-router-dom';
3
3
  import { TFunction } from 'i18next';
4
4
  import Dates from './Dates/Dates';
5
- import Booking from './Booking/Booking';
6
5
  import Header from './Header/Header';
7
6
  import Orders from './Orders/Orders';
8
7
  import Sort from './Sort/Sort';
9
8
  import Picks from './Picks/Picks';
10
9
  import Filters from './Filters/Filters';
11
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';
12
15
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
13
16
  import { FoundData } from '@autobusal/providers/types/routes';
17
+ import { viewItemList } from '@autobusal/providers/Setup/commerce';
18
+ import { item as commerceItem } from '../commerce';
14
19
  import { Refinement, BucketKey, fromParams, toParams, filterItems, buildFacets, priceRange, currencyOf, isRefined } from './refine';
15
20
 
16
21
  interface Props {
@@ -29,6 +34,17 @@ interface Props {
29
34
  const OWNED = [ 'sort', 'operators', 'times', 'direct', 'from_stops', 'to_stops', 'features', 'max_price' ];
30
35
 
31
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]);
47
+
32
48
  const [ searchParams, setSearchParams ] = useSearchParams();
33
49
  const [ open, setOpen ] = useState<boolean>(false);
34
50
 
@@ -72,15 +88,67 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
72
88
 
73
89
  const passengers = Number(step1.adults ?? 0) + Number(step1.children ?? 0) + Number(step1.babies ?? 0);
74
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
+
75
112
  // controls are pointless while there is nothing (or only one thing) to
76
113
  // refine, and they'd only add noise above an empty state
77
114
  const showControls = !loading && items.length > 1;
78
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
+
79
131
  return (
80
132
  <>
81
133
  <Dates type={ type } step1={ step1 } t={ t } onSearch={ onSearch } />
82
134
 
83
- { (data !== undefined && data.length > 0) && <Booking data={ data } step1={ step1 } t={ t } /> }
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. */ }
84
152
 
85
153
  { showControls && (
86
154
  <>
@@ -88,17 +156,6 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
88
156
  <Sort value={ refinement.sort } t={ t } onChange={ sort => onRefine({ ...refinement, sort }) } />
89
157
  </Summary>
90
158
 
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
159
  <Picks
103
160
  items={ refined }
104
161
  sort={ refinement.sort }
@@ -108,21 +165,63 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
108
165
  </>
109
166
  ) }
110
167
 
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
- />
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
+ ) }
126
225
  </>
127
226
  );
128
227
  };
@@ -8,6 +8,7 @@ import { FoundData } from '@autobusal/providers/types/routes';
8
8
  interface Props {
9
9
  items: FoundData[]
10
10
  type: 'favorite' | 'normal'
11
+ date?: string
11
12
  passengers: number
12
13
  t: TFunction<'common'>
13
14
  onSave: (data: FoundData) => void
@@ -23,7 +24,7 @@ interface Props {
23
24
  * booked, so there is no separate "now choose a time" step to forget: the
24
25
  * card always represents a bookable trip, starting with the earliest.
25
26
  */
26
- const Group = ({ items, type, passengers, t, onSave }: Props): JSX.Element => {
27
+ const Group = ({ items, type, date, passengers, t, onSave }: Props): JSX.Element => {
27
28
  // chronological for the picker, regardless of how the list itself is
28
29
  // sorted - a row of times out of time order is just confusing
29
30
  const departures = useMemo(() => (
@@ -38,6 +39,7 @@ const Group = ({ items, type, passengers, t, onSave }: Props): JSX.Element => {
38
39
  <Route
39
40
  type={ type }
40
41
  data={ current }
42
+ date={ date }
41
43
  passengers={ passengers }
42
44
  t={ t }
43
45
  onSave={ onSave }