@autobusal/routes-order 1.16.0 → 1.21.0
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 +92 -0
- package/Facts/types.ts +13 -0
- package/Found/Dates/Dates.tsx +59 -5
- package/Found/Dates/styles.ts +109 -7
- package/Found/Found.tsx +9 -0
- package/Found/Picks/Picks.tsx +10 -3
- package/Found/Picks/styles.ts +60 -9
- package/Found/Route/Route.tsx +35 -3
- package/Found/Route/styles.ts +239 -22
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/styles.ts +52 -7
- package/Links/Links.tsx +67 -0
- package/Links/styles.ts +45 -0
- package/Sections/Sections.tsx +20 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,98 @@ 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.0
|
|
10
|
+
|
|
11
|
+
The same-day notice no longer hedges about timezones. obtapi now holds a zone
|
|
12
|
+
per country, so "has this coach already left" is judged against the departure
|
|
13
|
+
stop's own clock — the warning is about the real remaining risk, which is
|
|
14
|
+
whether a traveller can reach the terminal in time.
|
|
15
|
+
|
|
16
|
+
## 1.20.0
|
|
17
|
+
|
|
18
|
+
The date strip: three days on a phone, five on a tablet, seven on a desktop —
|
|
19
|
+
and the label becomes "04 AUG" below desktop rather than "Tue 04".
|
|
20
|
+
|
|
21
|
+
Seven fitted on a phone once the labels were trimmed, but only at ~36px a
|
|
22
|
+
cell: readable, not comfortable to hit, with the price wrapping to three lines
|
|
23
|
+
under it. Each step up in width now buys two more days instead of squeezing
|
|
24
|
+
the same seven into less room.
|
|
25
|
+
|
|
26
|
+
The weekday is what gives way, not the month. With three days showing, a date
|
|
27
|
+
needs to say which date it is more than which weekday — "04 AUG" is
|
|
28
|
+
unambiguous alone, while "Tue 04" leaves somebody counting forward to work out
|
|
29
|
+
whether that is this week or next. On a full seven-day strip the weekday earns
|
|
30
|
+
its place, because the row reads as a week; on three days it does not.
|
|
31
|
+
|
|
32
|
+
## 1.19.0
|
|
33
|
+
|
|
34
|
+
Same-day travel, and the last of the phone layout.
|
|
35
|
+
|
|
36
|
+
**Today is bookable**, so today's results carry a countdown — "Leaves in 43
|
|
37
|
+
min", turning red under the hour. Computed server-side: a countdown built from
|
|
38
|
+
the visitor's own clock is wrong for anybody whose device is off or in another
|
|
39
|
+
timezone, and that is exactly the claim that must not be wrong.
|
|
40
|
+
|
|
41
|
+
**A same-day caution above the results.** A coach that has already gone is
|
|
42
|
+
filtered out server-side, but "already gone" is judged against our clock and
|
|
43
|
+
the system holds no timezone for a city — so on a cross-border route the
|
|
44
|
+
stored departure time and our idea of now can be an hour apart. The notice asks
|
|
45
|
+
the traveller to check against their own local time.
|
|
46
|
+
|
|
47
|
+
**Five days on a phone, seven above it.** Seven fitted after the labels were
|
|
48
|
+
trimmed, but only at ~36px a cell. The five are centred on the selected day and
|
|
49
|
+
clamped at both ends, so shrinking never hides the day being looked at.
|
|
50
|
+
|
|
51
|
+
**Fare and button share a row**, tax note beneath — reordered in CSS, because
|
|
52
|
+
the source order (fare, note, button) is the right one to read aloud and the
|
|
53
|
+
right one for a crawler: the note explains the number it follows.
|
|
54
|
+
|
|
55
|
+
## 1.18.0
|
|
56
|
+
|
|
57
|
+
The results page on a phone, rebuilt for density.
|
|
58
|
+
|
|
59
|
+
**The three picks are one strip.** Cheapest, fastest and recommended stacked
|
|
60
|
+
into three full-width bands before a single result — the thing that summarises
|
|
61
|
+
the list was pushing the list off the screen. Side by side they are comparable
|
|
62
|
+
at a glance, which is what they are for. The operator's name drops below 640px:
|
|
63
|
+
at ~110px a card it would ellipsis to three characters.
|
|
64
|
+
|
|
65
|
+
**All five sort options on one row.** They were wrapping onto three lines,
|
|
66
|
+
taking more height than the first result they reorder. Sized to their own text
|
|
67
|
+
rather than equal shares — equal shares gave all five 59px, which four did not
|
|
68
|
+
need and "Recommended" did, so it alone was truncated to "Recom..." while its
|
|
69
|
+
neighbours sat in whitespace.
|
|
70
|
+
|
|
71
|
+
**The seven-day strip no longer scrolls sideways.** A 900px floor made half the
|
|
72
|
+
week invisible on a phone, including whichever day was cheapest — and a week is
|
|
73
|
+
a shape you read at a glance, not one you drag. The month is dropped from each
|
|
74
|
+
label (it is identical across a seven-day window) and the week arrows are
|
|
75
|
+
squeezed; the day prices wrap rather than painting over each other.
|
|
76
|
+
|
|
77
|
+
**Details collapse.** Features, route, transit and policies were four stacked
|
|
78
|
+
bands on every result. They are one wrapped row behind a toggle now, hidden
|
|
79
|
+
with CSS rather than unmounted so the prerendered snapshot a crawler reads is
|
|
80
|
+
unchanged.
|
|
81
|
+
|
|
82
|
+
**The trip stacks vertically** with the arrow turned to point down. Side by
|
|
83
|
+
side was tried first and does not survive the content: "Thessaloniki (GRC)
|
|
84
|
+
16:30" measures 215px in a 96px column and painted 50px outside the card — the
|
|
85
|
+
page never scrolled sideways, so nothing looked broken until it was measured.
|
|
86
|
+
|
|
87
|
+
## 1.17.0
|
|
88
|
+
|
|
89
|
+
Internal linking blocks: "Popular routes from Tirana" / "Popular routes to
|
|
90
|
+
Thessaloniki", as real router links, because the entire value is that a
|
|
91
|
+
crawler follows them.
|
|
92
|
+
|
|
93
|
+
The sitemap already announces every sellable pair, but a sitemap is a hint —
|
|
94
|
+
a page nothing links to is still orphaned. These put every pair one click from
|
|
95
|
+
another.
|
|
96
|
+
|
|
97
|
+
Ordered by paid bookings, never filtered by them: the pairs with no orders yet
|
|
98
|
+
are exactly the orphans this exists to reach. The block also survives a pair
|
|
99
|
+
with no facts, which is precisely where a reader most needs a way onward.
|
|
100
|
+
|
|
9
101
|
## 1.16.0
|
|
10
102
|
|
|
11
103
|
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
|
}
|
package/Found/Dates/Dates.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
{
|
|
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>
|
package/Found/Dates/styles.ts
CHANGED
|
@@ -1,23 +1,105 @@
|
|
|
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:
|
|
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
|
+
overflow-x: auto;
|
|
11
|
+
padding: 10px 15px;
|
|
12
|
+
}
|
|
9
13
|
`;
|
|
10
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
17
|
+
*
|
|
18
|
+
* The 900px floor is what forced the strip to scroll sideways on a phone -
|
|
19
|
+
* seven days plus two arrows in a box the screen could not hold. A week is a
|
|
20
|
+
* shape people read at a glance, and a shape you have to drag is not one:
|
|
21
|
+
* half the week was simply invisible, including whichever day was cheapest.
|
|
22
|
+
*
|
|
23
|
+
* The floor now applies only from 640px up, where there is room for it.
|
|
24
|
+
* Below that the days share the width and the month is dropped from each
|
|
25
|
+
* label (see Month) so seven of them fit.
|
|
26
|
+
*/
|
|
11
27
|
export const Inner = styled.div`
|
|
12
|
-
min-width: 900px;
|
|
13
28
|
display: flex;
|
|
14
|
-
gap:
|
|
29
|
+
gap: 2px;
|
|
15
30
|
justify-content: center;
|
|
16
31
|
align-items: center;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The two week-stepping arrows, squeezed on a phone. At their default size
|
|
35
|
+
* they took 70px of a 355px row between them - a fifth of the week's worth
|
|
36
|
+
* of space spent on two chevrons, which left each day 34px and forced the
|
|
37
|
+
* prices to wrap three times.
|
|
38
|
+
*/
|
|
39
|
+
& > button:first-child,
|
|
40
|
+
& > button:last-child {
|
|
41
|
+
flex: 0 0 auto;
|
|
42
|
+
padding: 0 6px;
|
|
43
|
+
height: 30px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@media (min-width: 640px) {
|
|
47
|
+
min-width: 900px;
|
|
48
|
+
gap: 5px;
|
|
49
|
+
|
|
50
|
+
& > button:first-child,
|
|
51
|
+
& > button:last-child {
|
|
52
|
+
padding: 0 20px;
|
|
53
|
+
height: auto;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
60
|
+
*
|
|
61
|
+
* The WEEKDAY is what goes below a desktop, not the month.
|
|
62
|
+
*
|
|
63
|
+
* With three or five days showing rather than seven, a date needs to say
|
|
64
|
+
* which date it is more than it needs to say which weekday - "04 AUG" is
|
|
65
|
+
* unambiguous on its own, while "Tue 04" leaves somebody counting forward
|
|
66
|
+
* from today to work out whether that is this week or next. On a full
|
|
67
|
+
* seven-day strip the weekday earns its place, because the row reads as a
|
|
68
|
+
* week; on three days it does not.
|
|
69
|
+
*/
|
|
70
|
+
export const Weekday = styled.span`
|
|
71
|
+
display: none;
|
|
72
|
+
|
|
73
|
+
@media (min-width: 1024px) {
|
|
74
|
+
display: inline;
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
export const DayNumber = styled.span`
|
|
79
|
+
display: block;
|
|
80
|
+
line-height: 1.2;
|
|
81
|
+
|
|
82
|
+
@media (min-width: 1024px) {
|
|
83
|
+
display: inline;
|
|
84
|
+
|
|
85
|
+
&::before {
|
|
86
|
+
content: ', ';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
export const Month = styled.span`
|
|
92
|
+
text-transform: uppercase;
|
|
93
|
+
|
|
94
|
+
@media (min-width: 1024px) {
|
|
95
|
+
text-transform: none;
|
|
96
|
+
}
|
|
17
97
|
`;
|
|
18
98
|
|
|
19
99
|
export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean }>`
|
|
20
|
-
flex: 1;
|
|
100
|
+
flex: 1 1 0;
|
|
101
|
+
min-width: 0;
|
|
102
|
+
overflow: hidden;
|
|
21
103
|
display: flex;
|
|
22
104
|
flex-direction: column;
|
|
23
105
|
align-items: center;
|
|
@@ -27,10 +109,14 @@ export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean
|
|
|
27
109
|
min-height: 26px;
|
|
28
110
|
font-weight: 700;
|
|
29
111
|
text-align: center;
|
|
30
|
-
font-size: ${ props => props.theme.size.
|
|
112
|
+
font-size: ${ props => props.theme.size.xs };
|
|
31
113
|
border-radius: 12px;
|
|
32
114
|
transition: all 0.3s ease;
|
|
33
115
|
|
|
116
|
+
@media (min-width: 640px) {
|
|
117
|
+
font-size: ${ props => props.theme.size.s };
|
|
118
|
+
}
|
|
119
|
+
|
|
34
120
|
${ props => !props.$available && css`
|
|
35
121
|
opacity: .6;
|
|
36
122
|
` }
|
|
@@ -53,7 +139,23 @@ export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean
|
|
|
53
139
|
* so the price stays readable once the parent flips to the primary fill.
|
|
54
140
|
*/
|
|
55
141
|
export const DayPrice = styled.span<{ $cheapest: boolean }>`
|
|
56
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
144
|
+
* Allowed to WRAP on a phone. "38.00 EUR" is wider than the ~45px a
|
|
145
|
+
* seventh of the screen gives a cell, and held on one line it simply
|
|
146
|
+
* painted over its neighbours - seven prices overlapping into an
|
|
147
|
+
* unreadable smear, which is worse than the scroller this replaced. Two
|
|
148
|
+
* short lines fit.
|
|
149
|
+
*/
|
|
150
|
+
white-space: normal;
|
|
151
|
+
overflow-wrap: anywhere;
|
|
152
|
+
line-height: 1.15;
|
|
153
|
+
font-size: calc(${ props => props.theme.size.xs } - 1px);
|
|
154
|
+
|
|
155
|
+
@media (min-width: 640px) {
|
|
156
|
+
white-space: nowrap;
|
|
157
|
+
font-size: ${ props => props.theme.size.xs };
|
|
158
|
+
}
|
|
57
159
|
font-weight: ${ props => props.$cheapest ? 700 : 400 };
|
|
58
160
|
opacity: .85;
|
|
59
161
|
`;
|
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
|
package/Found/Picks/Picks.tsx
CHANGED
|
@@ -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 !== '-' &&
|
|
59
|
-
|
|
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
|
);
|
package/Found/Picks/styles.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
19
|
-
padding:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
`;
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
package/Found/Route/styles.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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:
|
|
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:
|
|
144
|
-
gap:
|
|
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:
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
flex-wrap: wrap;
|
|
416
|
+
gap: 15px;
|
|
250
417
|
}
|
|
251
418
|
`;
|
|
252
419
|
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
`;
|
package/Found/Sort/styles.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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:
|
|
19
|
-
flex-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
|
-
|
|
24
|
-
|
|
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 };
|
package/Links/Links.tsx
ADDED
|
@@ -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;
|
package/Links/styles.ts
ADDED
|
@@ -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
|
+
`;
|
package/Sections/Sections.tsx
CHANGED
|
@@ -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
|
|
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
|
};
|