@autobusal/routes-order 1.16.0 → 1.21.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,105 @@ All notable changes to this package are documented here. This project follows
6
6
  > Note: 1.8.0 was published without a changelog entry. The gap is left as-is
7
7
  > rather than reconstructed after the fact.
8
8
 
9
+ ## 1.21.1
10
+
11
+ No horizontal scroll on a tablet's date strip. The 900px floor belongs to the
12
+ seven-day layout, which only appears from 1024px — applied from 640px it made
13
+ a tablet showing five days scroll sideways for a row that fitted comfortably,
14
+ because the strip was reserving width for two cells it was not rendering.
15
+
16
+ ## 1.21.0
17
+
18
+ The same-day notice no longer hedges about timezones. obtapi now holds a zone
19
+ per country, so "has this coach already left" is judged against the departure
20
+ stop's own clock — the warning is about the real remaining risk, which is
21
+ whether a traveller can reach the terminal in time.
22
+
23
+ ## 1.20.0
24
+
25
+ The date strip: three days on a phone, five on a tablet, seven on a desktop —
26
+ and the label becomes "04 AUG" below desktop rather than "Tue 04".
27
+
28
+ Seven fitted on a phone once the labels were trimmed, but only at ~36px a
29
+ cell: readable, not comfortable to hit, with the price wrapping to three lines
30
+ under it. Each step up in width now buys two more days instead of squeezing
31
+ the same seven into less room.
32
+
33
+ The weekday is what gives way, not the month. With three days showing, a date
34
+ needs to say which date it is more than which weekday — "04 AUG" is
35
+ unambiguous alone, while "Tue 04" leaves somebody counting forward to work out
36
+ whether that is this week or next. On a full seven-day strip the weekday earns
37
+ its place, because the row reads as a week; on three days it does not.
38
+
39
+ ## 1.19.0
40
+
41
+ Same-day travel, and the last of the phone layout.
42
+
43
+ **Today is bookable**, so today's results carry a countdown — "Leaves in 43
44
+ min", turning red under the hour. Computed server-side: a countdown built from
45
+ the visitor's own clock is wrong for anybody whose device is off or in another
46
+ timezone, and that is exactly the claim that must not be wrong.
47
+
48
+ **A same-day caution above the results.** A coach that has already gone is
49
+ filtered out server-side, but "already gone" is judged against our clock and
50
+ the system holds no timezone for a city — so on a cross-border route the
51
+ stored departure time and our idea of now can be an hour apart. The notice asks
52
+ the traveller to check against their own local time.
53
+
54
+ **Five days on a phone, seven above it.** Seven fitted after the labels were
55
+ trimmed, but only at ~36px a cell. The five are centred on the selected day and
56
+ clamped at both ends, so shrinking never hides the day being looked at.
57
+
58
+ **Fare and button share a row**, tax note beneath — reordered in CSS, because
59
+ the source order (fare, note, button) is the right one to read aloud and the
60
+ right one for a crawler: the note explains the number it follows.
61
+
62
+ ## 1.18.0
63
+
64
+ The results page on a phone, rebuilt for density.
65
+
66
+ **The three picks are one strip.** Cheapest, fastest and recommended stacked
67
+ into three full-width bands before a single result — the thing that summarises
68
+ the list was pushing the list off the screen. Side by side they are comparable
69
+ at a glance, which is what they are for. The operator's name drops below 640px:
70
+ at ~110px a card it would ellipsis to three characters.
71
+
72
+ **All five sort options on one row.** They were wrapping onto three lines,
73
+ taking more height than the first result they reorder. Sized to their own text
74
+ rather than equal shares — equal shares gave all five 59px, which four did not
75
+ need and "Recommended" did, so it alone was truncated to "Recom..." while its
76
+ neighbours sat in whitespace.
77
+
78
+ **The seven-day strip no longer scrolls sideways.** A 900px floor made half the
79
+ week invisible on a phone, including whichever day was cheapest — and a week is
80
+ a shape you read at a glance, not one you drag. The month is dropped from each
81
+ label (it is identical across a seven-day window) and the week arrows are
82
+ squeezed; the day prices wrap rather than painting over each other.
83
+
84
+ **Details collapse.** Features, route, transit and policies were four stacked
85
+ bands on every result. They are one wrapped row behind a toggle now, hidden
86
+ with CSS rather than unmounted so the prerendered snapshot a crawler reads is
87
+ unchanged.
88
+
89
+ **The trip stacks vertically** with the arrow turned to point down. Side by
90
+ side was tried first and does not survive the content: "Thessaloniki (GRC)
91
+ 16:30" measures 215px in a 96px column and painted 50px outside the card — the
92
+ page never scrolled sideways, so nothing looked broken until it was measured.
93
+
94
+ ## 1.17.0
95
+
96
+ Internal linking blocks: "Popular routes from Tirana" / "Popular routes to
97
+ Thessaloniki", as real router links, because the entire value is that a
98
+ crawler follows them.
99
+
100
+ The sitemap already announces every sellable pair, but a sitemap is a hint —
101
+ a page nothing links to is still orphaned. These put every pair one click from
102
+ another.
103
+
104
+ Ordered by paid bookings, never filtered by them: the pairs with no orders yet
105
+ are exactly the orphans this exists to reach. The block also survives a pair
106
+ with no facts, which is precisely where a reader most needs a way onward.
107
+
9
108
  ## 1.16.0
10
109
 
11
110
  The crawlable timetable, and a section layout around the wizard.
package/Facts/types.ts CHANGED
@@ -40,8 +40,21 @@ export interface ScheduleRow {
40
40
  price: string
41
41
  }
42
42
 
43
+ export interface LinkRow {
44
+ from: string
45
+ to: string
46
+ from_name: string
47
+ to_name: string
48
+ url: string
49
+ // paid orders in the last year - ranks the list, never gates membership
50
+ orders: number
51
+ }
52
+
43
53
  export interface FactsResponse {
44
54
  // null for a pair that is not sold - an envelope rather than a bare null,
45
55
  // because `{}` is truthy in JavaScript
46
56
  facts: FactsData | null
57
+ // Tier 3.4 - pairs leaving the origin, and pairs arriving at the
58
+ // destination. Present even when `facts` is null.
59
+ links: { from: LinkRow[], to: LinkRow[] }
47
60
  }
@@ -2,9 +2,10 @@ import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
3
  import { AiOutlineCaretLeft, AiOutlineCaretRight } from 'react-icons/ai';
4
4
  import { Button } from '@autobusal/common';
5
- import { createDate, getFormattedShort } from '@autobusal/utilities';
5
+ import { useMediaQuery } from '@autobusal/hooks';
6
+ import { createDate, getDayParts } from '@autobusal/utilities';
6
7
  import Loading from './Loading';
7
- import { Container, Inner, ButtonDay, DayPrice, Cheapest } from './styles';
8
+ import { Container, Inner, ButtonDay, DayPrice, Cheapest, Weekday, DayNumber, Month } from './styles';
8
9
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
9
10
  import { FoundDay } from '../../types';
10
11
  import { useGetDates } from '../../services';
@@ -19,6 +20,24 @@ interface Props {
19
20
  const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
20
21
  const [number, setNumber] = useState<number>(0);
21
22
 
23
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
24
+ // Three days on a phone, five on a tablet, seven on a desktop.
25
+ //
26
+ // Seven fit on a phone without a scroller once the labels were trimmed,
27
+ // but only at about 36px a cell - enough to read, not enough to be
28
+ // comfortable to hit with a thumb, and with the price wrapping to three
29
+ // lines underneath. Each step up in width buys two more days rather than
30
+ // squeezing the same seven.
31
+ //
32
+ // This is one of the few things CSS genuinely cannot do: hiding cells
33
+ // would mean hiding FIXED positions, and the days that matter least are
34
+ // the ones furthest from the selected date, which moves.
35
+ const phone = useMediaQuery('(max-width: 639px)');
36
+
37
+ const tablet = useMediaQuery('(max-width: 1023px)');
38
+
39
+ const visible = phone ? 3 : (tablet ? 5 : 7);
40
+
22
41
  const { data, isLoading } = useGetDates(step1, type, number);
23
42
 
24
43
  if (isLoading) {
@@ -67,7 +86,34 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
67
86
  && Math.max(...prices) > (lowest as number)
68
87
  && atLowest * 2 <= prices.length;
69
88
 
70
- const items = data?.map(item => {
89
+ /**
90
+ * The days actually shown.
91
+ *
92
+ * Centred on the selected day where the window allows, so shrinking to
93
+ * five never hides the day somebody is looking at - and clamped at both
94
+ * ends, so a selection sitting at the very start or end of the week still
95
+ * yields five days rather than three.
96
+ */
97
+ const shown = (() => {
98
+ const days = data ?? [];
99
+
100
+ if (days.length <= visible) {
101
+ return days;
102
+ }
103
+
104
+ const selected = days.findIndex(item => item.day === step1[type]);
105
+
106
+ // Centre on the selected day, then clamp - so a selection at either end
107
+ // of the week still yields a full window rather than a short one.
108
+ const start = Math.min(
109
+ Math.max(0, (selected === -1 ? 0 : selected) - Math.floor(visible / 2)),
110
+ days.length - visible
111
+ );
112
+
113
+ return days.slice(start, start + visible);
114
+ })();
115
+
116
+ const items = shown.map(item => {
71
117
  const date = createDate(item.day);
72
118
 
73
119
  // if date in the future, and we have routes available
@@ -77,7 +123,15 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
77
123
 
78
124
  return (
79
125
  <ButtonDay key={ item.id } $available={ isAvailable } $selected={ step1[type] === item.day } onClick={ () => onClick(item, isAvailable) }>
80
- { getFormattedShort(date, t) }
126
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
127
+ In parts, so a phone can drop the month and fit seven days
128
+ without a horizontal scroller - see Month in styles. */ }
129
+ <Weekday>{ getDayParts(date, t).weekday }</Weekday>
130
+
131
+ <DayNumber>
132
+ { getDayParts(date, t).day }
133
+ <Month>{ ' ' + getDayParts(date, t).month }</Month>
134
+ </DayNumber>
81
135
 
82
136
  { isAvailable && item.from_price_display && (
83
137
  <DayPrice $cheapest={ isCheapest }>{ item.from_price_display }</DayPrice>
@@ -88,7 +142,7 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
88
142
  ) }
89
143
  </ButtonDay>
90
144
  );
91
- })
145
+ });
92
146
 
93
147
  return (
94
148
  <Container>
@@ -1,23 +1,125 @@
1
1
  import styled, { css } from 'styled-components';
2
2
 
3
3
  export const Container = styled.div`
4
- overflow-x: auto;
5
4
  margin-bottom: 15px;
6
- padding: 10px 15px;
5
+ padding: 8px 6px;
7
6
  background-color: ${ props => props.theme.background.neutral };
8
7
  border-radius: ${ props => props.theme.borderRadius };
8
+
9
+ @media (min-width: 640px) {
10
+ padding: 10px 15px;
11
+ }
12
+
13
+ /**
14
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
15
+ * The scroll container only exists where the fixed-width strip below does,
16
+ * which is desktop. It was switched on from 640px, so a tablet inherited a
17
+ * scrollbar for a strip that had every reason to fit.
18
+ */
19
+ @media (min-width: 1024px) {
20
+ overflow-x: auto;
21
+ }
9
22
  `;
10
23
 
24
+ /**
25
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
26
+ *
27
+ * The 900px floor is what forced the strip to scroll sideways on a phone -
28
+ * seven days plus two arrows in a box the screen could not hold. A week is a
29
+ * shape people read at a glance, and a shape you have to drag is not one:
30
+ * half the week was simply invisible, including whichever day was cheapest.
31
+ *
32
+ * The floor now applies only from 640px up, where there is room for it.
33
+ * Below that the days share the width and the month is dropped from each
34
+ * label (see Month) so seven of them fit.
35
+ */
11
36
  export const Inner = styled.div`
12
- min-width: 900px;
13
37
  display: flex;
14
- gap: 5px;
38
+ gap: 2px;
15
39
  justify-content: center;
16
40
  align-items: center;
41
+
42
+ /**
43
+ * The two week-stepping arrows, squeezed on a phone. At their default size
44
+ * they took 70px of a 355px row between them - a fifth of the week's worth
45
+ * of space spent on two chevrons, which left each day 34px and forced the
46
+ * prices to wrap three times.
47
+ */
48
+ & > button:first-child,
49
+ & > button:last-child {
50
+ flex: 0 0 auto;
51
+ padding: 0 6px;
52
+ height: 30px;
53
+ }
54
+
55
+ @media (min-width: 640px) {
56
+ gap: 5px;
57
+
58
+ & > button:first-child,
59
+ & > button:last-child {
60
+ padding: 0 20px;
61
+ height: auto;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
67
+ *
68
+ * The 900px floor belongs to the SEVEN-day strip, and seven days only
69
+ * appear from 1024px. Applied from 640px it forced a tablet showing FIVE
70
+ * days to scroll sideways for a row that fitted comfortably - the strip
71
+ * was reserving width for two cells that were not being rendered.
72
+ */
73
+ @media (min-width: 1024px) {
74
+ min-width: 900px;
75
+ }
76
+ `;
77
+
78
+ /**
79
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
80
+ *
81
+ * The WEEKDAY is what goes below a desktop, not the month.
82
+ *
83
+ * With three or five days showing rather than seven, a date needs to say
84
+ * which date it is more than it needs to say which weekday - "04 AUG" is
85
+ * unambiguous on its own, while "Tue 04" leaves somebody counting forward
86
+ * from today to work out whether that is this week or next. On a full
87
+ * seven-day strip the weekday earns its place, because the row reads as a
88
+ * week; on three days it does not.
89
+ */
90
+ export const Weekday = styled.span`
91
+ display: none;
92
+
93
+ @media (min-width: 1024px) {
94
+ display: inline;
95
+ }
96
+ `;
97
+
98
+ export const DayNumber = styled.span`
99
+ display: block;
100
+ line-height: 1.2;
101
+
102
+ @media (min-width: 1024px) {
103
+ display: inline;
104
+
105
+ &::before {
106
+ content: ', ';
107
+ }
108
+ }
109
+ `;
110
+
111
+ export const Month = styled.span`
112
+ text-transform: uppercase;
113
+
114
+ @media (min-width: 1024px) {
115
+ text-transform: none;
116
+ }
17
117
  `;
18
118
 
19
119
  export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean }>`
20
- flex: 1;
120
+ flex: 1 1 0;
121
+ min-width: 0;
122
+ overflow: hidden;
21
123
  display: flex;
22
124
  flex-direction: column;
23
125
  align-items: center;
@@ -27,10 +129,14 @@ export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean
27
129
  min-height: 26px;
28
130
  font-weight: 700;
29
131
  text-align: center;
30
- font-size: ${ props => props.theme.size.s };
132
+ font-size: ${ props => props.theme.size.xs };
31
133
  border-radius: 12px;
32
134
  transition: all 0.3s ease;
33
135
 
136
+ @media (min-width: 640px) {
137
+ font-size: ${ props => props.theme.size.s };
138
+ }
139
+
34
140
  ${ props => !props.$available && css`
35
141
  opacity: .6;
36
142
  ` }
@@ -53,7 +159,23 @@ export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean
53
159
  * so the price stays readable once the parent flips to the primary fill.
54
160
  */
55
161
  export const DayPrice = styled.span<{ $cheapest: boolean }>`
56
- font-size: ${ props => props.theme.size.xs };
162
+ /**
163
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
164
+ * Allowed to WRAP on a phone. "38.00 EUR" is wider than the ~45px a
165
+ * seventh of the screen gives a cell, and held on one line it simply
166
+ * painted over its neighbours - seven prices overlapping into an
167
+ * unreadable smear, which is worse than the scroller this replaced. Two
168
+ * short lines fit.
169
+ */
170
+ white-space: normal;
171
+ overflow-wrap: anywhere;
172
+ line-height: 1.15;
173
+ font-size: calc(${ props => props.theme.size.xs } - 1px);
174
+
175
+ @media (min-width: 640px) {
176
+ white-space: nowrap;
177
+ font-size: ${ props => props.theme.size.xs };
178
+ }
57
179
  font-weight: ${ props => props.$cheapest ? 700 : 400 };
58
180
  opacity: .85;
59
181
  `;
package/Found/Found.tsx CHANGED
@@ -8,6 +8,7 @@ import Sort from './Sort/Sort';
8
8
  import Picks from './Picks/Picks';
9
9
  import Filters from './Filters/Filters';
10
10
  import Summary from './Summary/Summary';
11
+ import SameDay from './SameDay/SameDay';
11
12
  import { Results, Sidebar, Listing } from '../styles';
12
13
  import { useGetAlternatives } from '../services';
13
14
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
@@ -89,6 +90,14 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
89
90
  <>
90
91
  <Dates type={ type } step1={ step1 } t={ t } onSearch={ onSearch } />
91
92
 
93
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
94
+ Keyed off obtapi sending a countdown at all, which it does only
95
+ for a same-day search - so there is no second copy of "is this
96
+ today" here to fall out of step with the server's. */ }
97
+ { items.some(item => item.departs_in !== null && item.departs_in !== undefined) && (
98
+ <SameDay t={ t } />
99
+ ) }
100
+
92
101
  { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
93
102
  The Booking.com banner used to sit here, between the date strip
94
103
  and the trips, pushing the actual results below it to advertise a
@@ -1,5 +1,5 @@
1
1
  import { TFunction } from 'i18next';
2
- import { Container, Pick, Label, Value, Meta } from './styles';
2
+ import { Container, Pick, Label, Value, Meta, Company } from './styles';
3
3
  import { SortKey, sortItems } from '../refine';
4
4
  import { FoundData } from '@autobusal/providers/types/routes';
5
5
 
@@ -55,8 +55,15 @@ const Picks = ({ items, sort, t, onSelect }: Props): JSX.Element | null => {
55
55
  <Value>{ best.price.display }</Value>
56
56
 
57
57
  <Meta>
58
- { best.duration !== '-' && `${ best.duration } · ` }
59
- { best.operator.company }
58
+ { best.duration !== '-' && best.duration }
59
+
60
+ { /* The operator's name is hidden on a phone - see Company in
61
+ styles. The separator goes with it, or a card reads
62
+ "4h ·" with nothing after the dot. */ }
63
+ <Company>
64
+ { best.duration !== '-' && ' · ' }
65
+ { best.operator.company }
66
+ </Company>
60
67
  </Meta>
61
68
  </Pick>
62
69
  );
@@ -1,13 +1,22 @@
1
1
  import styled, { css } from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
5
+ *
6
+ * Three across at every width, including phones, where these used to stack.
7
+ * Stacked, they cost three full bands of vertical space before a single
8
+ * result - so the thing that exists to summarise the list was pushing the
9
+ * list itself off the screen. Side by side they are a strip, and comparing
10
+ * three numbers is what they are for; a column of three is a list to read.
11
+ */
3
12
  export const Container = styled.div`
4
13
  display: grid;
5
- gap: 10px;
14
+ gap: 6px;
6
15
  margin-bottom: 15px;
7
- grid-template-columns: 1fr;
16
+ grid-template-columns: repeat(3, 1fr);
8
17
 
9
18
  @media (min-width: 640px) {
10
- grid-template-columns: repeat(3, 1fr);
19
+ gap: 10px;
11
20
  }
12
21
  `;
13
22
 
@@ -15,9 +24,15 @@ export const Pick = styled.button<{ $active: boolean }>`
15
24
  display: flex;
16
25
  flex-direction: column;
17
26
  align-items: flex-start;
18
- gap: 4px;
19
- padding: 12px 15px;
27
+ gap: 2px;
28
+ padding: 8px 9px;
20
29
  text-align: left;
30
+ min-width: 0;
31
+
32
+ @media (min-width: 640px) {
33
+ gap: 4px;
34
+ padding: 12px 15px;
35
+ }
21
36
  border-radius: ${ props => props.theme.borderRadius };
22
37
  border: 1px solid ${ props => props.theme.inputs.border };
23
38
  background: ${ props => props.theme.background.normal };
@@ -34,22 +49,58 @@ export const Pick = styled.button<{ $active: boolean }>`
34
49
  `;
35
50
 
36
51
  export const Label = styled.span`
37
- font-size: ${ props => props.theme.size.xs };
52
+ max-width: 100%;
53
+ overflow: hidden;
54
+ font-size: calc(${ props => props.theme.size.xs } - 1px);
38
55
  text-transform: uppercase;
39
- letter-spacing: .5px;
56
+ letter-spacing: 0;
40
57
  font-weight: 700;
41
58
  color: ${ props => props.theme.font.faded };
59
+ text-overflow: ellipsis;
60
+ white-space: nowrap;
61
+
62
+ @media (min-width: 640px) {
63
+ font-size: ${ props => props.theme.size.xs };
64
+ letter-spacing: .5px;
65
+ }
42
66
  `;
43
67
 
44
68
  export const Value = styled.strong`
45
- font-size: ${ props => props.theme.size.l };
69
+ font-size: ${ props => props.theme.size.m };
70
+
71
+ @media (min-width: 640px) {
72
+ font-size: ${ props => props.theme.size.l };
73
+ }
46
74
  `;
47
75
 
48
76
  export const Meta = styled.span`
49
77
  overflow: hidden;
50
78
  max-width: 100%;
51
- font-size: ${ props => props.theme.size.xs };
79
+ font-size: calc(${ props => props.theme.size.xs } - 1px);
52
80
  color: ${ props => props.theme.font.faded };
53
81
  text-overflow: ellipsis;
54
82
  white-space: nowrap;
83
+
84
+ @media (min-width: 640px) {
85
+ font-size: ${ props => props.theme.size.xs };
86
+ }
87
+ `;
88
+
89
+ /**
90
+ * The operator's name, dropped on a phone.
91
+ *
92
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
93
+ *
94
+ * At a third of a 375px screen each card gets about 110px, and a company
95
+ * name is the one thing here that will not fit in it - it would ellipsis to
96
+ * two or three characters, which is noise rather than information. The
97
+ * duration stays, because it is short and it is the number being compared;
98
+ * the name is one line down in the results anyway.
99
+ */
100
+ export const Company = styled.span`
101
+ display: none;
102
+
103
+ @media (min-width: 640px) {
104
+ display: inline;
105
+ }
55
106
  `;
@@ -1,11 +1,11 @@
1
1
  import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
- import { HiOutlineArrowNarrowRight, HiOutlineDocumentText } from 'react-icons/hi';
3
+ import { HiOutlineArrowNarrowRight, HiOutlineDocumentText, HiChevronDown, HiChevronUp } from 'react-icons/hi';
4
4
  import { BiHourglass } from 'react-icons/bi';
5
5
  import { RiBus2Line } from 'react-icons/ri';
6
6
  import { formatDuration, splitPrice } from '../refine';
7
7
  import { Button, RouteFeature, Policy, Rating } from '@autobusal/common';
8
- import { Container, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, Details, Detail, ButtonPolicy, TitleDetail, LinkRoute, FeaturesItems } from './styles';
8
+ import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, Details, Detail, ButtonPolicy, TitleDetail, LinkRoute, FeaturesItems, Leaves } from './styles';
9
9
  import { FoundData } from '@autobusal/providers/types/routes';
10
10
 
11
11
  // neutral placeholder so a missing/broken operator logo doesn't render the
@@ -27,6 +27,14 @@ interface Props {
27
27
  const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Element => {
28
28
  const [ policy, setPolicy ] = useState<boolean>(false);
29
29
 
30
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
31
+ // Closed to begin with. Amenities, route code, transit and policies are
32
+ // supporting detail on a row somebody is scanning, and four bands of it
33
+ // per result pushed the next result off a phone screen. The content stays
34
+ // in the DOM either way (see Details in styles) so the prerendered
35
+ // snapshot a crawler reads is unchanged.
36
+ const [ details, setDetails ] = useState<boolean>(false);
37
+
30
38
  const customs = Object.values(data.customs).join(', ');
31
39
  const transiting = Object.values(data.transiting).join(', ');
32
40
 
@@ -47,11 +55,26 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
47
55
  ? `${ import.meta.env.VITE_API_OBT }/api/external/go/${ data.external.offer_id }`
48
56
  : undefined;
49
57
 
58
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
59
+ // Only today's results carry this - obtapi sends null on every other date,
60
+ // so there is no "in 26 days" badge to suppress here.
61
+ const leaves = data.departs_in ?? null;
62
+
63
+ const soon = leaves !== null && leaves < 60;
64
+
50
65
  return (
51
66
  <Container className="box" $type={ type }>
52
67
  { data.price.offer === true && <SpecialOffer>{ t('routes_order.step2.route.offer') }</SpecialOffer> }
53
68
  { type === 'favorite' && <FavoriteStop>{ t('routes_order.step2.route.preferred_stop') }</FavoriteStop> }
54
69
 
70
+ { leaves !== null && (
71
+ <Leaves $soon={ soon }>
72
+ { leaves < 60
73
+ ? t('routes_order.step2.route.leaves_minutes', { minutes: leaves })
74
+ : t('routes_order.step2.route.leaves_hours', { hours: Math.floor(leaves / 60), minutes: leaves % 60 }) }
75
+ </Leaves>
76
+ ) }
77
+
55
78
  <Trip>
56
79
  <Company>
57
80
  <LinkCompany to={ bookOnUrl ?? data.operator.link } target="_blank" rel="noopener noreferrer" title={ data.operator.company }><img src={ data.operator.logo_url || LOGO_FALLBACK } onError={ (e) => { e.currentTarget.onerror = null; e.currentTarget.src = LOGO_FALLBACK; } } alt={ data.operator.company } /></LinkCompany>
@@ -134,7 +157,16 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
134
157
 
135
158
  { children }
136
159
 
137
- <Details>
160
+ <ButtonDetails
161
+ type="button"
162
+ aria-expanded={ details }
163
+ onClick={ () => setDetails(!details) }
164
+ >
165
+ { details ? <HiChevronUp /> : <HiChevronDown /> }
166
+ { t('routes_order.step2.route.details') }
167
+ </ButtonDetails>
168
+
169
+ <Details $open={ details }>
138
170
  { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
139
171
  Amenities were a separate full-width block under the card.
140
172
  They are the same kind of thing as route, transit and policies
@@ -6,8 +6,20 @@ export const Container = styled.div<{ $type: 'favorite' | 'normal' }>`
6
6
  position: relative;
7
7
  display: flex;
8
8
  flex-direction: column;
9
- gap: 15px;
10
- padding: 15px;
9
+
10
+ /**
11
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
12
+ * Tighter on a phone. Every result carried 15px of padding and 15px
13
+ * between each of its bands, which is most of a thumb of screen spent on
14
+ * gaps before anything is read.
15
+ */
16
+ gap: 8px;
17
+ padding: 10px;
18
+
19
+ @media (min-width: 640px) {
20
+ gap: 15px;
21
+ padding: 15px;
22
+ }
11
23
 
12
24
  ${ props => props.$type === 'favorite' && css`
13
25
  border: 1px solid ${ props => props.theme.font.success };
@@ -45,14 +57,32 @@ export const FavoriteStop = styled.div`
45
57
  border-bottom-right-radius: ${ props => props.theme.borderRadius };
46
58
  `;
47
59
 
60
+ /**
61
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
62
+ *
63
+ * VERTICAL on a phone, side by side from 640px up.
64
+ *
65
+ * Side by side was tried first and does not survive the content: each end
66
+ * gets about 96px at 375px, while "Thessaloniki (GRC) 16:30" measures 215px
67
+ * - it painted 50px outside the card, and the page never scrolled sideways
68
+ * so nothing looked broken until it was measured against the box it was
69
+ * supposed to be in. Stacked, each end has the full width, so nothing is
70
+ * truncated and nothing escapes.
71
+ *
72
+ * Compact is bought back a different way: tight gaps, a short arrow turned
73
+ * to point down the column, and the price on one row instead of a centred
74
+ * stack. Same information, roughly a third less height than before.
75
+ */
48
76
  export const Trip = styled.div`
49
77
  display: flex;
50
78
  flex-direction: column;
51
- gap: 15px;
79
+ gap: 6px;
52
80
 
53
81
  @media (min-width: 640px) {
54
82
  flex-direction: row;
55
83
  flex-wrap: wrap;
84
+ align-items: center;
85
+ gap: 15px;
56
86
  }
57
87
 
58
88
  @media (min-width: 1024px) {
@@ -95,12 +125,49 @@ export const Location = styled.div`
95
125
  min-width: 0;
96
126
  display: flex;
97
127
  flex-direction: column;
98
- align-items: center;
99
- gap: 3px;
100
- text-align: center;
101
128
 
129
+ /**
130
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
131
+ * Left-aligned while the ends are stacked - centred text in a full-width
132
+ * column leaves the two times floating in the middle of the card with
133
+ * nothing to line them up against. Centred again from 640px, where the
134
+ * two ends sit either side of the arrow and the symmetry is the point.
135
+ */
136
+ align-items: flex-start;
137
+ gap: 1px;
138
+ text-align: left;
139
+
140
+ @media (min-width: 640px) {
141
+ align-items: center;
142
+ gap: 3px;
143
+ text-align: center;
144
+ }
145
+
146
+ /**
147
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
148
+ *
149
+ * The heading has to be allowed to WRAP now that the two ends share a
150
+ * row. The min-width:0 above lets this column shrink, but the heading
151
+ * inside kept its intrinsic single-line width and simply painted outside
152
+ * the card - "Thessaloniki (GRC) 16:30" measured 215px inside a 96px
153
+ * column and ran 50px past the card's right edge. The page never scrolled
154
+ * sideways, so nothing looked broken until the text was measured against
155
+ * the box it was supposed to be in.
156
+ *
157
+ * A step down in size as well, because three columns of a 375px screen do
158
+ * not have room for a headline at body-copy scale.
159
+ */
102
160
  & > h2 {
103
161
  margin-bottom: 0;
162
+ max-width: 100%;
163
+ line-height: 1.25;
164
+ overflow-wrap: anywhere;
165
+ }
166
+
167
+ @media (min-width: 640px) {
168
+ & > h2 {
169
+ overflow-wrap: normal;
170
+ }
104
171
  }
105
172
  `;
106
173
 
@@ -111,11 +178,30 @@ export const Location = styled.div`
111
178
  * body copy it competed with the city above it; a step down lets the eye
112
179
  * land on the city first and read the stop only when it needs to.
113
180
  */
181
+ /**
182
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
183
+ *
184
+ * Clamped to two lines on a phone. Now that the two ends of the journey
185
+ * share a row, each gets about 96px - and a name like "Terminali Lindor
186
+ * Interurban dhe Nderkombetar i Autobusave (TEG)" ran to five lines and
187
+ * stretched the whole row to match, so one terminal with a long name made
188
+ * every card taller than the screen. Two lines and an ellipsis, which is
189
+ * what the reference layouts do with the same problem.
190
+ */
114
191
  export const Stop = styled.span`
192
+ display: -webkit-box;
193
+ -webkit-line-clamp: 2;
194
+ -webkit-box-orient: vertical;
195
+ overflow: hidden;
115
196
  font-size: ${ props => props.theme.size.xs };
116
197
  font-weight: 700;
117
198
  line-height: 1.3;
118
199
  color: ${ props => props.theme.font.faded };
200
+
201
+ @media (min-width: 1024px) {
202
+ display: block;
203
+ overflow: visible;
204
+ }
119
205
  `;
120
206
 
121
207
  /**
@@ -140,13 +226,39 @@ export const Iso = styled.span`
140
226
 
141
227
  export const Time = styled.div`
142
228
  display: flex;
143
- flex-direction: column;
144
- gap: 8px;
229
+ flex-direction: row;
230
+ gap: 6px;
145
231
  align-items: center;
146
232
  font-weight: 700;
147
233
 
234
+ @media (min-width: 640px) {
235
+ flex-direction: column;
236
+ gap: 8px;
237
+ }
238
+
239
+ /**
240
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
241
+ * The arrow sat between two stacked blocks at 1.5x the largest type in
242
+ * the theme. Now that the two ends of the journey share a row with it,
243
+ * that size would take the width the city names need - it is a
244
+ * separator, not a headline.
245
+ */
246
+ /**
247
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
248
+ * Turned to point DOWN while the two ends are stacked - an arrow pointing
249
+ * right between two things sitting one above the other says the wrong
250
+ * thing about which way the journey goes.
251
+ */
148
252
  & > svg {
149
- font-size: calc(1.5 * ${ props => props.theme.size.xxl });
253
+ font-size: ${ props => props.theme.size.l };
254
+ transform: rotate(90deg);
255
+ }
256
+
257
+ @media (min-width: 640px) {
258
+ & > svg {
259
+ font-size: calc(1.5 * ${ props => props.theme.size.xxl });
260
+ transform: none;
261
+ }
150
262
  }
151
263
 
152
264
  & > span {
@@ -176,12 +288,44 @@ export const Time = styled.div`
176
288
  export const Price = styled.div`
177
289
  flex-basis: 100%;
178
290
  display: flex;
179
- flex-direction: column;
180
- gap: 8px;
291
+
292
+ /**
293
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
294
+ *
295
+ * On a phone: the fare and the button share one row, and the tax note sits
296
+ * under them.
297
+ *
298
+ * Reordered in CSS rather than in the markup, because the source order -
299
+ * fare, note, button - is the right one to read aloud and the right one
300
+ * for a crawler: the note explains the number it follows. Only the
301
+ * PAINTING needs to differ, and the order property says exactly that
302
+ * without moving the note away from the price it qualifies.
303
+ */
304
+ flex-direction: row;
305
+ flex-wrap: wrap;
306
+ justify-content: space-between;
181
307
  align-items: center;
308
+ gap: 4px 8px;
309
+
310
+ & > *:nth-child(1) { order: 1; }
311
+
312
+ /* the note drops to its own line under both */
313
+ & > *:nth-child(2) { order: 3; flex-basis: 100%; }
314
+
315
+ & > *:nth-child(3) { order: 2; }
182
316
 
183
317
  @media (min-width: 768px) {
184
318
  flex: 0 0 165px;
319
+ flex-direction: column;
320
+ justify-content: initial;
321
+ gap: 8px;
322
+
323
+ & > *:nth-child(1),
324
+ & > *:nth-child(2),
325
+ & > *:nth-child(3) {
326
+ order: initial;
327
+ flex-basis: auto;
328
+ }
185
329
  }
186
330
  `;
187
331
 
@@ -220,11 +364,20 @@ export const Currency = styled.span`
220
364
  * frees the vertical space the Select button needs.
221
365
  */
222
366
  export const PriceNotice = styled.span`
223
- margin-top: -4px;
367
+ /**
368
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
369
+ * Left-aligned under the fare on a phone, where it is a full-width line
370
+ * beneath a price and a button rather than the middle of a centred stack.
371
+ */
224
372
  font-size: ${ props => props.theme.size.xxs };
225
373
  line-height: 1.25;
226
374
  color: ${ props => props.theme.font.faded };
227
- text-align: center;
375
+ text-align: left;
376
+
377
+ @media (min-width: 768px) {
378
+ margin-top: -4px;
379
+ text-align: center;
380
+ }
228
381
  `;
229
382
 
230
383
  export const LinkCompany = styled(Link)`
@@ -239,24 +392,64 @@ export const LinkCompany = styled(Link)`
239
392
  }
240
393
  `;
241
394
 
242
- export const Details = styled.div`
243
- display: flex;
244
- flex-direction: column;
245
- gap: 15px;
395
+ /**
396
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
397
+ *
398
+ * Collapsible, and inline at every width including phones.
399
+ *
400
+ * Hidden with CSS rather than unmounted, deliberately. These cards are on a
401
+ * page the prerender crawl snapshots for every route pair in the sitemap, so
402
+ * amenities, the route code, transit countries and the policy link have to
403
+ * be IN the DOM whether or not a reader has opened them - unmounting would
404
+ * take real per-pair content out of exactly the pages Tier 3 exists to
405
+ * fatten.
406
+ *
407
+ * Wrapping instead of stacking on a phone turns four bands of vertical space
408
+ * into one or two, which is the space the results themselves wanted.
409
+ */
410
+ export const Details = styled.div<{ $open: boolean }>`
411
+ display: ${ props => (props.$open ? 'flex' : 'none') };
412
+ flex-wrap: wrap;
413
+ gap: 6px;
246
414
 
247
415
  @media (min-width: 640px) {
248
- flex-direction: row;
249
- flex-wrap: wrap;
416
+ gap: 15px;
250
417
  }
251
418
  `;
252
419
 
253
- export const Detail = styled.div`
254
- padding: 8px 10px;
420
+ /**
421
+ * The disclosure. Small and quiet - it is a way to get at supporting detail,
422
+ * not an action competing with "select".
423
+ */
424
+ export const ButtonDetails = styled.button`
425
+ display: inline-flex;
426
+ align-items: center;
427
+ gap: 5px;
428
+ align-self: flex-start;
429
+ padding: 4px 0;
255
430
  font-size: ${ props => props.theme.size.s };
431
+ color: ${ props => props.theme.font.faded };
432
+
433
+ &:hover {
434
+ color: ${ props => props.theme.font.normal };
435
+ }
436
+
437
+ svg {
438
+ transition: transform 0.2s ease;
439
+ }
440
+ `;
441
+
442
+ export const Detail = styled.div`
443
+ flex: 1 1 auto;
444
+ min-width: 0;
445
+ padding: 5px 8px;
446
+ font-size: calc(${ props => props.theme.size.s } - 1px);
256
447
  background: ${ props => props.theme.background.neutral };
257
448
  border-radius: ${ props => props.theme.borderRadius };
258
449
 
259
450
  @media (min-width: 480px) {
451
+ padding: 8px 10px;
452
+ font-size: ${ props => props.theme.size.s };
260
453
  flex-basis: calc(50% - 7.5px);
261
454
  }
262
455
  @media (min-width: 768px) {
@@ -300,4 +493,28 @@ export const FeaturesItems = styled.div`
300
493
  display: flex;
301
494
  flex-wrap: wrap;
302
495
  gap: 6px;
303
- `;
496
+ `;
497
+
498
+ /**
499
+ * "Leaves in 40 minutes", on today's departures only.
500
+ *
501
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
502
+ *
503
+ * Same-day seats are sellable now, and a time alone does not tell somebody
504
+ * whether they can make it - "14:00" reads very differently at nine in the
505
+ * morning and at half past one. Under an hour it turns urgent, because that
506
+ * is the point where the answer stops being "yes" and starts being "only if
507
+ * you leave now".
508
+ */
509
+ export const Leaves = styled.div<{ $soon: boolean }>`
510
+ align-self: flex-start;
511
+ padding: 3px 10px;
512
+ border-radius: 100px;
513
+ font-size: ${ props => props.theme.size.xs };
514
+ font-weight: 700;
515
+ white-space: nowrap;
516
+
517
+ color: ${ props => (props.$soon ? '#FFF' : props.theme.font.normal) };
518
+ background: ${ props => (props.$soon ? props.theme.font.error : props.theme.primary.neutral) };
519
+ `;
520
+
@@ -0,0 +1,35 @@
1
+ import { TFunction } from 'i18next';
2
+ import { HiOutlineExclamationCircle } from 'react-icons/hi';
3
+ import { Container } from './styles';
4
+
5
+ interface Props {
6
+ t: TFunction<'common'>
7
+ }
8
+
9
+ /**
10
+ * The caution that comes with selling today's seats.
11
+ *
12
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
13
+ *
14
+ * Same-day departures are sellable now, and two things about them are worth
15
+ * saying plainly before somebody pays.
16
+ *
17
+ * A coach that has already left is filtered out server-side
18
+ * (Search\Departed), and since countries carry a timezone that filter is now
19
+ * judged against the DEPARTURE STOP's own clock rather than ours - so the
20
+ * warning is no longer about doubt on our side.
21
+ *
22
+ * What remains is the honest thing to say when the margin is minutes: every
23
+ * time on the page is local to its stop, the seat is real, the coach is
24
+ * about to go, and whether somebody can reach the terminal in time is not
25
+ * something we can know for them.
26
+ */
27
+ const SameDay = ({ t }: Props): JSX.Element => (
28
+ <Container>
29
+ <HiOutlineExclamationCircle />
30
+
31
+ <span>{ t('routes_order.step2.same_day') }</span>
32
+ </Container>
33
+ );
34
+
35
+ export default SameDay;
@@ -0,0 +1,27 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
5
+ *
6
+ * Shown only on a same-day search. A caution, not an alarm - the whole point
7
+ * of selling today is that these seats are worth selling, and a red banner
8
+ * over every one of them would say the opposite.
9
+ */
10
+ export const Container = styled.div`
11
+ display: flex;
12
+ gap: 8px;
13
+ align-items: flex-start;
14
+ margin-bottom: 15px;
15
+ padding: 10px 12px;
16
+ border-radius: ${ props => props.theme.borderRadius };
17
+ background: ${ props => props.theme.primary.neutral };
18
+ color: ${ props => props.theme.font.normal };
19
+ font-size: ${ props => props.theme.size.s };
20
+ line-height: 1.4;
21
+
22
+ & > svg {
23
+ flex: 0 0 auto;
24
+ margin-top: 2px;
25
+ font-size: ${ props => props.theme.size.l };
26
+ }
27
+ `;
@@ -1,10 +1,24 @@
1
1
  import styled, { css } from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
5
+ *
6
+ * A column on a phone, so the five options get the whole width for one row
7
+ * instead of losing a third of it to the "Sort by" label and wrapping onto
8
+ * three lines.
9
+ */
3
10
  export const Container = styled.div`
4
11
  display: flex;
5
- align-items: center;
6
- gap: 8px;
7
- flex-wrap: wrap;
12
+ flex-direction: column;
13
+ align-items: stretch;
14
+ gap: 6px;
15
+
16
+ @media (min-width: 640px) {
17
+ flex-direction: row;
18
+ align-items: center;
19
+ gap: 8px;
20
+ flex-wrap: wrap;
21
+ }
8
22
  `;
9
23
 
10
24
  export const Label = styled.span`
@@ -13,17 +27,48 @@ export const Label = styled.span`
13
27
  color: ${ props => props.theme.font.faded };
14
28
  `;
15
29
 
30
+ /**
31
+ * One row, always. Five short options wrapping onto three lines took more
32
+ * height than the first result they were meant to reorder.
33
+ */
16
34
  export const Options = styled.div`
17
35
  display: flex;
18
- gap: 6px;
19
- flex-wrap: wrap;
36
+ gap: 3px;
37
+ flex-wrap: nowrap;
38
+
39
+ @media (min-width: 640px) {
40
+ gap: 6px;
41
+ flex-wrap: wrap;
42
+ }
20
43
  `;
21
44
 
22
45
  export const Option = styled.button<{ $active: boolean }>`
23
- padding: 6px 14px;
24
- font-size: ${ props => props.theme.size.s };
46
+ /**
47
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
48
+ *
49
+ * NATURAL width, allowed to shrink - not an equal share. Equal shares gave
50
+ * every option 59px, which four of the five did not need and the fifth
51
+ * did: "Recommended" wants 71px and was the only one ever truncated, to
52
+ * "Recom...", while its neighbours sat in whitespace. Left to size
53
+ * themselves the five come to 311px inside a 335px row and all fit.
54
+ *
55
+ * They can still shrink, so a language with longer words degrades to
56
+ * ellipsis rather than pushing an option off the edge.
57
+ */
58
+ flex: 0 1 auto;
59
+ min-width: 0;
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ padding: 5px 2px;
63
+ font-size: calc(${ props => props.theme.size.xs } - 2px);
25
64
  font-weight: 700;
26
65
  white-space: nowrap;
66
+
67
+ @media (min-width: 640px) {
68
+ flex: initial;
69
+ padding: 6px 14px;
70
+ font-size: ${ props => props.theme.size.s };
71
+ }
27
72
  border-radius: 100px;
28
73
  border: 1px solid ${ props => props.theme.inputs.border };
29
74
  background: ${ props => props.theme.background.normal };
@@ -0,0 +1,67 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Container, Group, Heading, List, Item } from './styles';
3
+ import { LinkRow } from '../Facts/types';
4
+
5
+ interface Props {
6
+ id: string
7
+ from: LinkRow[]
8
+ to: LinkRow[]
9
+ fromName?: string
10
+ toName?: string
11
+ t: TFunction<'common'>
12
+ }
13
+
14
+ /**
15
+ * Where else you can go from here.
16
+ *
17
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
18
+ *
19
+ * Roadmap Tier 3.4. The sitemap announces every sellable pair, but a sitemap
20
+ * is a hint - a page nothing links to is still orphaned, and a crawler that
21
+ * has to be TOLD about a page treats it as less important than one it
22
+ * arrives at by following a link. These blocks put every pair one click from
23
+ * another.
24
+ *
25
+ * Real router links, deliberately: the whole value is that they are followed.
26
+ * Ordered by paid bookings server-side, but never filtered by them - the
27
+ * pairs with no orders yet are exactly the orphans this exists to reach.
28
+ */
29
+ const Links = ({ id, from, to, fromName, toName, t }: Props): (JSX.Element | null) => {
30
+ if (from.length === 0 && to.length === 0) {
31
+ return null;
32
+ }
33
+
34
+ return (
35
+ <Container id={ id } className="box">
36
+ { from.length > 0 && (
37
+ <Group>
38
+ <Heading>{ t('routes_order.links.from', { city: fromName ?? from[0].from_name }) }</Heading>
39
+
40
+ <List>
41
+ { from.map(row => (
42
+ <li key={ row.url }>
43
+ <Item to={ row.url }>{ row.to_name }</Item>
44
+ </li>
45
+ )) }
46
+ </List>
47
+ </Group>
48
+ ) }
49
+
50
+ { to.length > 0 && (
51
+ <Group>
52
+ <Heading>{ t('routes_order.links.to', { city: toName ?? to[0].to_name }) }</Heading>
53
+
54
+ <List>
55
+ { to.map(row => (
56
+ <li key={ row.url }>
57
+ <Item to={ row.url }>{ row.from_name }</Item>
58
+ </li>
59
+ )) }
60
+ </List>
61
+ </Group>
62
+ ) }
63
+ </Container>
64
+ );
65
+ };
66
+
67
+ export default Links;
@@ -0,0 +1,45 @@
1
+ import styled from 'styled-components';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ export const Container = styled.section`
5
+ margin-top: 25px;
6
+ `;
7
+
8
+ export const Group = styled.div`
9
+ & + & {
10
+ margin-top: 20px;
11
+ }
12
+ `;
13
+
14
+ export const Heading = styled.h3`
15
+ margin: 0 0 10px;
16
+ font-size: ${ props => props.theme.size.m };
17
+ `;
18
+
19
+ export const List = styled.ul`
20
+ display: flex;
21
+ flex-wrap: wrap;
22
+ gap: 8px;
23
+ margin: 0;
24
+ padding: 0;
25
+ list-style: none;
26
+ `;
27
+
28
+ /**
29
+ * A real router Link, not a styled div with a click handler - the entire
30
+ * point of these blocks is that a crawler follows them.
31
+ */
32
+ export const Item = styled(Link)`
33
+ display: inline-block;
34
+ padding: 6px 12px;
35
+ border-radius: ${ props => props.theme.borderRadius };
36
+ background: ${ props => props.theme.background.neutral };
37
+ color: ${ props => props.theme.font.normal };
38
+ font-size: ${ props => props.theme.size.s };
39
+ white-space: nowrap;
40
+
41
+ &:hover {
42
+ text-decoration: none;
43
+ background: ${ props => props.theme.primary.neutral };
44
+ }
45
+ `;
@@ -1,6 +1,7 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import Facts from '../Facts/Facts';
3
3
  import Schedule from '../Schedule/Schedule';
4
+ import Links from '../Links/Links';
4
5
  import { useGetFacts } from '../Facts/services';
5
6
  import { Nav, Anchor, Anchored } from './styles';
6
7
 
@@ -13,6 +14,7 @@ interface Props {
13
14
  const TRIPS = 'trips';
14
15
  const FACTS = 'facts';
15
16
  const SCHEDULE = 'schedule';
17
+ const LINKS = 'links';
16
18
 
17
19
  /**
18
20
  * Everything on a route-pair page that is not the booking wizard.
@@ -37,8 +39,16 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
37
39
 
38
40
  const facts = data?.facts;
39
41
 
42
+ const links = data?.links ?? { from: [], to: [] };
43
+
44
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
45
+ // The linking block survives a pair with no facts. A pair that cannot be
46
+ // sold is precisely where somebody most needs a way onward - dropping the
47
+ // links there would leave them on a dead end.
40
48
  if (!facts) {
41
- return null;
49
+ return links.from.length > 0 || links.to.length > 0
50
+ ? <Links id={ LINKS } from={ links.from } to={ links.to } t={ t } />
51
+ : null;
42
52
  }
43
53
 
44
54
  const schedule = facts.schedule ?? [];
@@ -61,6 +71,15 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
61
71
  <Anchored>
62
72
  <Schedule id={ SCHEDULE } from={ facts.from } to={ facts.to } rows={ schedule } t={ t } />
63
73
  </Anchored>
74
+
75
+ <Links
76
+ id={ LINKS }
77
+ from={ links.from }
78
+ to={ links.to }
79
+ fromName={ facts.from }
80
+ toName={ facts.to }
81
+ t={ t }
82
+ />
64
83
  </>
65
84
  );
66
85
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.16.0",
3
+ "version": "1.21.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"