@autobusal/routes-order 1.12.0 → 1.16.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 +56 -0
- package/Facts/Facts.tsx +119 -0
- package/Facts/services.ts +19 -0
- package/Facts/styles.ts +48 -0
- package/Facts/types.ts +47 -0
- package/Found/Route/Route.tsx +9 -1
- package/Found/Route/styles.ts +11 -0
- package/RoutesOrder.tsx +27 -0
- package/Schedule/Schedule.tsx +110 -0
- package/Schedule/styles.ts +53 -0
- package/Sections/Sections.tsx +68 -0
- package/Sections/styles.ts +44 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,62 @@ 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.16.0
|
|
10
|
+
|
|
11
|
+
The crawlable timetable, and a section layout around the wizard.
|
|
12
|
+
|
|
13
|
+
Every service on the pair as a real `<table>` — departs, arrives, duration,
|
|
14
|
+
stops, operator with its rating, price — sorted by departure time. This is the
|
|
15
|
+
part of Tier 3 that actually ranks: dense, unique to the pair, and made of the
|
|
16
|
+
words somebody types, without a sentence of written copy. No interactivity, so
|
|
17
|
+
a prerendered snapshot shows a non-JS crawler exactly what a reader sees.
|
|
18
|
+
|
|
19
|
+
A sticky in-page nav (Trips · Facts · Timetable) so somebody who landed from a
|
|
20
|
+
search engine can reach the timetable without scrolling past a booking form
|
|
21
|
+
they have not decided to use. The wizard itself is untouched — this is a
|
|
22
|
+
layout around it, not a change to the funnel.
|
|
23
|
+
|
|
24
|
+
Facts and the timetable share ONE request. Two components each fetching the
|
|
25
|
+
same response would have doubled what the prerender crawl costs on every pair.
|
|
26
|
+
|
|
27
|
+
No "type" or "class" column, which the roadmap asked for: every route in this
|
|
28
|
+
system is typed `international` and the bus names are inventory identifiers
|
|
29
|
+
like "A100". A column that says nothing on every row reads as padding.
|
|
30
|
+
|
|
31
|
+
## 1.15.0
|
|
32
|
+
|
|
33
|
+
Distance is now measured along the route's own stops rather than as one line
|
|
34
|
+
from origin to destination, and the facts payload carries two numbers instead
|
|
35
|
+
of one: `road`, real driving distance from a routing service, and `approx`,
|
|
36
|
+
the straight lines summed stop by stop.
|
|
37
|
+
|
|
38
|
+
Real road distance prints plainly. The approximation prints with a caveat,
|
|
39
|
+
because it understates every journey — 309 km against 401 km of actual tarmac
|
|
40
|
+
on Tirana–Thessaloniki via Korça, a 23% shortfall. A straight line is the
|
|
41
|
+
shortest path there is, so no number of extra points can ever close that gap.
|
|
42
|
+
|
|
43
|
+
## 1.14.0
|
|
44
|
+
|
|
45
|
+
The facts block on a route-pair page: fares from, journey time, first and last
|
|
46
|
+
departure, how many run, distance and who operates it.
|
|
47
|
+
|
|
48
|
+
Read off the timetable rather than a dated search, so it does not change with
|
|
49
|
+
the date in the URL — a "from EUR 12" that moved depending on which day a
|
|
50
|
+
crawler arrived would look unstable to a search engine and dishonest to a
|
|
51
|
+
reader who came back.
|
|
52
|
+
|
|
53
|
+
Distance is labelled "straight line" because that is what it is: we hold city
|
|
54
|
+
coordinates, not routing data, so the figure is always shorter than the road.
|
|
55
|
+
|
|
56
|
+
Renders nothing at all for a pair with no facts. An empty facts block is worse
|
|
57
|
+
than none — it is the thin content this tier exists to remove.
|
|
58
|
+
|
|
59
|
+
## 1.13.0
|
|
60
|
+
|
|
61
|
+
The operator's rating on each result card. The operator's rather than the
|
|
62
|
+
route's: a single route rarely has enough published reviews to clear the
|
|
63
|
+
display threshold, and a card with a rating beats one without.
|
|
64
|
+
|
|
9
65
|
## [1.12.0] - 2026-08-01
|
|
10
66
|
|
|
11
67
|
### Removed
|
package/Facts/Facts.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Container, Grid, Fact, Label, Value, Note, Operators } from './styles';
|
|
3
|
+
import { FactsData } from './types';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
id: string
|
|
7
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
8
|
+
// Handed in rather than fetched here. The schedule table is built from the
|
|
9
|
+
// same response, and two components each running the same query would have
|
|
10
|
+
// meant the prerender crawl paying for every pair twice.
|
|
11
|
+
facts: FactsData
|
|
12
|
+
t: TFunction<'common'>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The facts about this city pair.
|
|
17
|
+
*
|
|
18
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
19
|
+
*
|
|
20
|
+
* Roadmap Tier 3.2. What somebody searching "Tirana to Durres bus" wants
|
|
21
|
+
* answered before they trust the page: cost, length, when the first and last
|
|
22
|
+
* coach go, how many there are, how far, and who runs it.
|
|
23
|
+
*
|
|
24
|
+
* Everything here is read off the TIMETABLE rather than a dated search, so it
|
|
25
|
+
* does not change with the date in the URL. That is deliberate: a "from EUR
|
|
26
|
+
* 12" that moved depending on which day a crawler arrived would look unstable
|
|
27
|
+
* to a search engine and dishonest to a reader who came back.
|
|
28
|
+
*
|
|
29
|
+
* Renders nothing at all when the API has no facts for the pair. An empty
|
|
30
|
+
* facts block is worse than none - it is exactly the thin content this tier
|
|
31
|
+
* exists to remove.
|
|
32
|
+
*/
|
|
33
|
+
const Facts = ({ id, facts, t }: Props): JSX.Element => {
|
|
34
|
+
// "07:30" built from a duration in minutes reads as a time of day, so the
|
|
35
|
+
// hours and minutes are spelled out instead.
|
|
36
|
+
const duration = (minutes: number): string => (
|
|
37
|
+
t('routes_order.facts.duration_value', {
|
|
38
|
+
hours: Math.floor(minutes / 60),
|
|
39
|
+
minutes: minutes % 60
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const range = facts.duration_min !== null && facts.duration_max !== null
|
|
44
|
+
? (facts.duration_min === facts.duration_max
|
|
45
|
+
? duration(facts.duration_min)
|
|
46
|
+
: `${ duration(facts.duration_min) } – ${ duration(facts.duration_max) }`)
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Container id={ id } className="box">
|
|
51
|
+
<h2>{ t('routes_order.facts.title', { from: facts.from, to: facts.to }) }</h2>
|
|
52
|
+
|
|
53
|
+
<Grid>
|
|
54
|
+
{ facts.price_from_display && (
|
|
55
|
+
<Fact>
|
|
56
|
+
<Label>{ t('routes_order.facts.price') }</Label>
|
|
57
|
+
<Value>{ facts.price_from_display }</Value>
|
|
58
|
+
</Fact>
|
|
59
|
+
) }
|
|
60
|
+
|
|
61
|
+
{ range && (
|
|
62
|
+
<Fact>
|
|
63
|
+
<Label>{ t('routes_order.facts.duration') }</Label>
|
|
64
|
+
<Value>{ range }</Value>
|
|
65
|
+
</Fact>
|
|
66
|
+
) }
|
|
67
|
+
|
|
68
|
+
{ facts.departure_first && (
|
|
69
|
+
<Fact>
|
|
70
|
+
<Label>{ t('routes_order.facts.first') }</Label>
|
|
71
|
+
<Value>{ facts.departure_first }</Value>
|
|
72
|
+
</Fact>
|
|
73
|
+
) }
|
|
74
|
+
|
|
75
|
+
{ facts.departure_last && (
|
|
76
|
+
<Fact>
|
|
77
|
+
<Label>{ t('routes_order.facts.last') }</Label>
|
|
78
|
+
<Value>{ facts.departure_last }</Value>
|
|
79
|
+
</Fact>
|
|
80
|
+
) }
|
|
81
|
+
|
|
82
|
+
<Fact>
|
|
83
|
+
<Label>{ t('routes_order.facts.departures') }</Label>
|
|
84
|
+
<Value>{ facts.departures }</Value>
|
|
85
|
+
</Fact>
|
|
86
|
+
|
|
87
|
+
{ facts.distance && (
|
|
88
|
+
<Fact>
|
|
89
|
+
<Label>{ t('routes_order.facts.distance') }</Label>
|
|
90
|
+
|
|
91
|
+
<Value>
|
|
92
|
+
{ t('routes_order.facts.distance_value', {
|
|
93
|
+
km: facts.distance.road ?? facts.distance.approx
|
|
94
|
+
}) }
|
|
95
|
+
|
|
96
|
+
{ /* The caveat appears only on the approximation. A real road
|
|
97
|
+
distance needs no apology; a sum of straight lines does,
|
|
98
|
+
because it understates every journey - 309 km against 401
|
|
99
|
+
km of tarmac on Tirana-Thessaloniki. Printing that bare, as
|
|
100
|
+
though it were the driving distance, would be quietly wrong
|
|
101
|
+
on every single pair. */ }
|
|
102
|
+
{ facts.distance.road === null && (
|
|
103
|
+
<Note>{ t('routes_order.facts.distance_note') }</Note>
|
|
104
|
+
) }
|
|
105
|
+
</Value>
|
|
106
|
+
</Fact>
|
|
107
|
+
) }
|
|
108
|
+
</Grid>
|
|
109
|
+
|
|
110
|
+
{ facts.operators.length > 0 && (
|
|
111
|
+
<Operators>
|
|
112
|
+
{ t('routes_order.facts.operators', { operators: facts.operators.join(', ') }) }
|
|
113
|
+
</Operators>
|
|
114
|
+
) }
|
|
115
|
+
</Container>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default Facts;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { UseQueryResult, useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { apiClient } from '@autobusal/providers';
|
|
3
|
+
import { FactsResponse } from './types';
|
|
4
|
+
|
|
5
|
+
export const useGetFacts = (from?: string, to?: string): UseQueryResult<FactsResponse> => (
|
|
6
|
+
useQuery({
|
|
7
|
+
queryKey: ['route-facts', from, to],
|
|
8
|
+
queryFn: async () => (
|
|
9
|
+
await apiClient
|
|
10
|
+
.get('/api/routes/search/facts', {
|
|
11
|
+
params: { from, to }
|
|
12
|
+
})
|
|
13
|
+
.then(response => (
|
|
14
|
+
response.data
|
|
15
|
+
))
|
|
16
|
+
),
|
|
17
|
+
enabled: Boolean(from && to)
|
|
18
|
+
})
|
|
19
|
+
);
|
package/Facts/styles.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.section`
|
|
4
|
+
margin-top: 25px;
|
|
5
|
+
`;
|
|
6
|
+
|
|
7
|
+
export const Grid = styled.dl`
|
|
8
|
+
display: grid;
|
|
9
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
10
|
+
gap: 18px;
|
|
11
|
+
margin: 0;
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
export const Fact = styled.div`
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: 3px;
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
export const Label = styled.dt`
|
|
21
|
+
font-size: ${ props => props.theme.size.s };
|
|
22
|
+
color: ${ props => props.theme.font.faded };
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export const Value = styled.dd`
|
|
26
|
+
margin: 0;
|
|
27
|
+
font-size: ${ props => props.theme.size.l };
|
|
28
|
+
font-weight: 700;
|
|
29
|
+
line-height: 1.2;
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Qualifies a value that is not quite what its label implies - the straight
|
|
34
|
+
* line distance, which is always shorter than the road.
|
|
35
|
+
*/
|
|
36
|
+
export const Note = styled.span`
|
|
37
|
+
display: block;
|
|
38
|
+
font-size: ${ props => props.theme.size.xs };
|
|
39
|
+
font-weight: 400;
|
|
40
|
+
color: ${ props => props.theme.font.faded };
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
export const Operators = styled.p`
|
|
44
|
+
margin: 20px 0 0;
|
|
45
|
+
color: ${ props => props.theme.font.faded };
|
|
46
|
+
font-size: ${ props => props.theme.size.s };
|
|
47
|
+
line-height: 1.5;
|
|
48
|
+
`;
|
package/Facts/types.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface FactsData {
|
|
2
|
+
from: string
|
|
3
|
+
to: string
|
|
4
|
+
// cheapest adult fare anybody sells this leg for
|
|
5
|
+
price_from: number | null
|
|
6
|
+
// formatted by the API with the brand's own currency - the PUBLIC settings
|
|
7
|
+
// payload carries no currency, so a frontend has nothing to format with
|
|
8
|
+
price_from_display: string | null
|
|
9
|
+
// both ends of the range, in minutes
|
|
10
|
+
duration_min: number | null
|
|
11
|
+
duration_max: number | null
|
|
12
|
+
departure_first: string | null
|
|
13
|
+
departure_last: string | null
|
|
14
|
+
// how many coaches serve the pair in the TIMETABLE, not on a given date
|
|
15
|
+
departures: number
|
|
16
|
+
operators: string[]
|
|
17
|
+
// Two numbers that must never be conflated.
|
|
18
|
+
//
|
|
19
|
+
// `road` is real driving distance from a routing service and is null unless
|
|
20
|
+
// one is configured. `approx` sums straight lines stop by stop - always
|
|
21
|
+
// available, and ALWAYS SHORT, because a straight line is the shortest path
|
|
22
|
+
// there is. Measured on Tirana-Thessaloniki via Korca: approx 309 km
|
|
23
|
+
// against 401 km of actual road, a 23% understatement. Show `road` plainly
|
|
24
|
+
// when it exists; show `approx` only with its caveat attached.
|
|
25
|
+
distance: { road: number | null, approx: number } | null
|
|
26
|
+
// every service on the pair, sorted by departure - Tier 3.3
|
|
27
|
+
schedule: ScheduleRow[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ScheduleRow {
|
|
31
|
+
route: string
|
|
32
|
+
operator: string | null
|
|
33
|
+
// null until the operator has enough published reviews - see Tier 2
|
|
34
|
+
rating: { average: number, count: number } | null
|
|
35
|
+
departure: string | null
|
|
36
|
+
arrival: string | null
|
|
37
|
+
minutes: number | null
|
|
38
|
+
// 0 is a direct service
|
|
39
|
+
stops: number
|
|
40
|
+
price: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FactsResponse {
|
|
44
|
+
// null for a pair that is not sold - an envelope rather than a bare null,
|
|
45
|
+
// because `{}` is truthy in JavaScript
|
|
46
|
+
facts: FactsData | null
|
|
47
|
+
}
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { HiOutlineArrowNarrowRight, HiOutlineDocumentText } 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
|
-
import { Button, RouteFeature, Policy } from '@autobusal/common';
|
|
7
|
+
import { Button, RouteFeature, Policy, Rating } from '@autobusal/common';
|
|
8
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';
|
|
9
9
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
10
10
|
|
|
@@ -55,6 +55,14 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
|
|
|
55
55
|
<Trip>
|
|
56
56
|
<Company>
|
|
57
57
|
<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>
|
|
58
|
+
|
|
59
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
60
|
+
The OPERATOR's rating rather than this route's: a single route
|
|
61
|
+
rarely has enough published reviews to clear the display
|
|
62
|
+
threshold, and a card with a rating beats one without. Renders
|
|
63
|
+
nothing when there is no rating yet, which is why a brand-new
|
|
64
|
+
operator does not look like a badly rated one. */ }
|
|
65
|
+
<Rating rating={ data.operator.rating } size="small" t={ t } />
|
|
58
66
|
</Company>
|
|
59
67
|
|
|
60
68
|
<Location>
|
package/Found/Route/styles.ts
CHANGED
|
@@ -63,6 +63,17 @@ export const Trip = styled.div`
|
|
|
63
63
|
export const Company = styled.div`
|
|
64
64
|
flex-basis: 100%;
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
68
|
+
* A column now, because the operator's rating sits under the logo. Left
|
|
69
|
+
* as a bare block it would have laid the stars out beside the logo and
|
|
70
|
+
* eaten the 185px this cell gets on desktop.
|
|
71
|
+
*/
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
gap: 6px;
|
|
75
|
+
align-items: flex-start;
|
|
76
|
+
|
|
66
77
|
@media (min-width: 1024px) {
|
|
67
78
|
flex: 0 0 185px;
|
|
68
79
|
}
|
package/RoutesOrder.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { useGetSettings } from '@autobusal/providers/services';
|
|
|
8
8
|
import RoutesSearch from '@autobusal/routes-search';
|
|
9
9
|
import { useGetSuggestions } from '@autobusal/routes-search/services';
|
|
10
10
|
import { SearchAgain, SearchInner, SearchToggle, SearchSummary, SearchPanel } from './styles';
|
|
11
|
+
import Sections from './Sections/Sections';
|
|
11
12
|
import Step1 from './Step1/Step1';
|
|
12
13
|
import Step2 from './Step2';
|
|
13
14
|
import Step3 from './Step3';
|
|
@@ -163,6 +164,13 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
163
164
|
/>
|
|
164
165
|
) }
|
|
165
166
|
|
|
167
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
168
|
+
The anchor the section nav's "Trips" link lands on. A plain
|
|
169
|
+
wrapper: the wizard inside is untouched, which is the whole point
|
|
170
|
+
of Tier 3.1 - the funnel keeps working exactly as it did and the
|
|
171
|
+
new sections sit around it. */ }
|
|
172
|
+
<div id="trips">
|
|
173
|
+
|
|
166
174
|
{ showStep2 && (
|
|
167
175
|
<Step2
|
|
168
176
|
step1={ step1 }
|
|
@@ -184,6 +192,25 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
184
192
|
/>
|
|
185
193
|
) }
|
|
186
194
|
|
|
195
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
196
|
+
Roadmap Tier 3.2. Only on the route-choosing screens, and only
|
|
197
|
+
once the URL names a real pair: this is the page a search engine
|
|
198
|
+
lands on, and it is the one place the block adds anything. Past
|
|
199
|
+
step 2 the reader has picked a coach and is filling in passenger
|
|
200
|
+
details - facts about the pair are noise at that point.
|
|
201
|
+
|
|
202
|
+
It renders nothing at all for a pair with no facts, so a page that
|
|
203
|
+
would only have gained an empty box does not gain a box. */ }
|
|
204
|
+
{ (showStep2 || showStep3) && (
|
|
205
|
+
<Sections
|
|
206
|
+
from={ fromCity?.slug ?? params.from }
|
|
207
|
+
to={ toCity?.slug ?? params.to }
|
|
208
|
+
t={ t }
|
|
209
|
+
/>
|
|
210
|
+
) }
|
|
211
|
+
|
|
212
|
+
</div>
|
|
213
|
+
|
|
187
214
|
{ showCheckout && (
|
|
188
215
|
<Checkout
|
|
189
216
|
values={ step4 }
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Rating } from '@autobusal/common';
|
|
3
|
+
import { Container, Table, Time, Faded, Price, Operator } from './styles';
|
|
4
|
+
import { ScheduleRow } from '../Facts/types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
id: string
|
|
8
|
+
from: string
|
|
9
|
+
to: string
|
|
10
|
+
rows: ScheduleRow[]
|
|
11
|
+
t: TFunction<'common'>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every service on this city pair, as a table.
|
|
16
|
+
*
|
|
17
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
18
|
+
*
|
|
19
|
+
* Roadmap Tier 3.3, and the part of this tier that actually ranks. A page
|
|
20
|
+
* about "Tirana to Durres bus" competes on being dense, unique to the pair,
|
|
21
|
+
* and made of the words somebody types - and a timetable is all three at
|
|
22
|
+
* once, without a single sentence of written copy.
|
|
23
|
+
*
|
|
24
|
+
* A real <table>, not a grid of divs, because this genuinely is tabular data:
|
|
25
|
+
* the same structure that makes it readable is what lets a crawler
|
|
26
|
+
* understand the columns. No interactivity at all, so a prerendered snapshot
|
|
27
|
+
* shows a non-JS crawler exactly what a reader sees.
|
|
28
|
+
*
|
|
29
|
+
* Sorted by departure, server-side. Price order would scatter the day and
|
|
30
|
+
* make the table useless for the thing people actually do with a timetable,
|
|
31
|
+
* which is plan around a time.
|
|
32
|
+
*/
|
|
33
|
+
const Schedule = ({ id, from, to, rows, t }: Props): (JSX.Element | null) => {
|
|
34
|
+
if (rows.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const duration = (minutes: number | null): string => {
|
|
39
|
+
if (minutes === null) {
|
|
40
|
+
return '-';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hours = Math.floor(minutes / 60);
|
|
44
|
+
const rest = minutes % 60;
|
|
45
|
+
|
|
46
|
+
// "8h 0m" is a number pretending to be a measurement
|
|
47
|
+
return rest === 0
|
|
48
|
+
? t('routes_order.facts.duration_hours', { hours })
|
|
49
|
+
: t('routes_order.facts.duration_value', { hours, minutes: rest });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Container id={ id } className="box">
|
|
54
|
+
<h2>{ t('routes_order.schedule.title', { from, to }) }</h2>
|
|
55
|
+
|
|
56
|
+
<Table>
|
|
57
|
+
<thead>
|
|
58
|
+
<tr>
|
|
59
|
+
<th>{ t('routes_order.schedule.departure') }</th>
|
|
60
|
+
<th>{ t('routes_order.schedule.arrival') }</th>
|
|
61
|
+
<th>{ t('routes_order.schedule.duration') }</th>
|
|
62
|
+
<th>{ t('routes_order.schedule.stops') }</th>
|
|
63
|
+
<th>{ t('routes_order.schedule.operator') }</th>
|
|
64
|
+
<th>{ t('routes_order.schedule.price') }</th>
|
|
65
|
+
</tr>
|
|
66
|
+
</thead>
|
|
67
|
+
|
|
68
|
+
<tbody>
|
|
69
|
+
{ rows.map((row, index) => (
|
|
70
|
+
<tr key={ index }>
|
|
71
|
+
<td><Time>{ row.departure ?? '-' }</Time></td>
|
|
72
|
+
<td><Time>{ row.arrival ?? '-' }</Time></td>
|
|
73
|
+
<td><Faded>{ duration(row.minutes) }</Faded></td>
|
|
74
|
+
|
|
75
|
+
<td>
|
|
76
|
+
{ /* "Direct" is the word people scan for; "0 stops" makes
|
|
77
|
+
the reader do that translation themselves.
|
|
78
|
+
|
|
79
|
+
Above zero it is the bare NUMBER, not "{{count}} stops":
|
|
80
|
+
the column header already says Stops, and an interpolated
|
|
81
|
+
noun rendered "1 stops" - this codebase has no i18next
|
|
82
|
+
plural forms anywhere, and adding them for one cell would
|
|
83
|
+
mean getting three-form Slavic plurals right in fifteen
|
|
84
|
+
languages to say something the header already said. */ }
|
|
85
|
+
<Faded>
|
|
86
|
+
{ row.stops === 0 ? t('routes_order.schedule.direct') : row.stops }
|
|
87
|
+
</Faded>
|
|
88
|
+
</td>
|
|
89
|
+
|
|
90
|
+
<td>
|
|
91
|
+
<Operator>
|
|
92
|
+
{ row.operator ?? '-' }
|
|
93
|
+
|
|
94
|
+
{ /* Renders nothing until the operator has enough
|
|
95
|
+
published reviews - see Tier 2. A new operator must
|
|
96
|
+
not read as a badly rated one. */ }
|
|
97
|
+
<Rating rating={ row.rating } size="small" t={ t } />
|
|
98
|
+
</Operator>
|
|
99
|
+
</td>
|
|
100
|
+
|
|
101
|
+
<td><Price>{ row.price }</Price></td>
|
|
102
|
+
</tr>
|
|
103
|
+
)) }
|
|
104
|
+
</tbody>
|
|
105
|
+
</Table>
|
|
106
|
+
</Container>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export default Schedule;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.section`
|
|
4
|
+
margin-top: 25px;
|
|
5
|
+
overflow-x: auto;
|
|
6
|
+
`;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A real table, because this IS tabular data - the same reason a crawler
|
|
10
|
+
* understands it. Scrolls inside its own container on a narrow screen rather
|
|
11
|
+
* than pushing the page sideways.
|
|
12
|
+
*/
|
|
13
|
+
export const Table = styled.table`
|
|
14
|
+
width: 100%;
|
|
15
|
+
border-collapse: collapse;
|
|
16
|
+
min-width: 620px;
|
|
17
|
+
|
|
18
|
+
th, td {
|
|
19
|
+
padding: 10px 12px;
|
|
20
|
+
text-align: left;
|
|
21
|
+
border-bottom: 1px solid ${ props => props.theme.background.neutral };
|
|
22
|
+
white-space: nowrap;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
th {
|
|
26
|
+
font-size: ${ props => props.theme.size.s };
|
|
27
|
+
color: ${ props => props.theme.font.faded };
|
|
28
|
+
font-weight: 400;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
tbody tr:last-child td {
|
|
32
|
+
border-bottom: none;
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
export const Time = styled.strong`
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
export const Faded = styled.span`
|
|
41
|
+
color: ${ props => props.theme.font.faded };
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export const Price = styled.strong`
|
|
45
|
+
font-weight: 700;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
export const Operator = styled.div`
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
gap: 2px;
|
|
53
|
+
`;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import Facts from '../Facts/Facts';
|
|
3
|
+
import Schedule from '../Schedule/Schedule';
|
|
4
|
+
import { useGetFacts } from '../Facts/services';
|
|
5
|
+
import { Nav, Anchor, Anchored } from './styles';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
from?: string
|
|
9
|
+
to?: string
|
|
10
|
+
t: TFunction<'common'>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TRIPS = 'trips';
|
|
14
|
+
const FACTS = 'facts';
|
|
15
|
+
const SCHEDULE = 'schedule';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Everything on a route-pair page that is not the booking wizard.
|
|
19
|
+
*
|
|
20
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
21
|
+
*
|
|
22
|
+
* Roadmap Tier 3.1. The wizard stays exactly where it is and keeps working
|
|
23
|
+
* exactly as it did - this is a layout around it, not a rewrite of the
|
|
24
|
+
* funnel. Somebody who arrived ready to book still books; somebody who
|
|
25
|
+
* arrived from a search engine asking "how long does that take" now has an
|
|
26
|
+
* answer without scrolling through a form first.
|
|
27
|
+
*
|
|
28
|
+
* ONE fetch for both sections. Facts and Schedule are built from the same
|
|
29
|
+
* response, and letting each fetch its own would have doubled what the
|
|
30
|
+
* prerender crawl costs on every pair in the sitemap.
|
|
31
|
+
*
|
|
32
|
+
* The nav renders only when there is more than the wizard to navigate to -
|
|
33
|
+
* a bar whose only link is "Trips" is furniture.
|
|
34
|
+
*/
|
|
35
|
+
const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
36
|
+
const { data } = useGetFacts(from, to);
|
|
37
|
+
|
|
38
|
+
const facts = data?.facts;
|
|
39
|
+
|
|
40
|
+
if (!facts) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const schedule = facts.schedule ?? [];
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<Nav aria-label={ t('routes_order.sections.label') }>
|
|
49
|
+
<Anchor href={ `#${ TRIPS }` }>{ t('routes_order.sections.trips') }</Anchor>
|
|
50
|
+
<Anchor href={ `#${ FACTS }` }>{ t('routes_order.sections.facts') }</Anchor>
|
|
51
|
+
|
|
52
|
+
{ schedule.length > 0 && (
|
|
53
|
+
<Anchor href={ `#${ SCHEDULE }` }>{ t('routes_order.sections.schedule') }</Anchor>
|
|
54
|
+
) }
|
|
55
|
+
</Nav>
|
|
56
|
+
|
|
57
|
+
<Anchored>
|
|
58
|
+
<Facts id={ FACTS } facts={ facts } t={ t } />
|
|
59
|
+
</Anchored>
|
|
60
|
+
|
|
61
|
+
<Anchored>
|
|
62
|
+
<Schedule id={ SCHEDULE } from={ facts.from } to={ facts.to } rows={ schedule } t={ t } />
|
|
63
|
+
</Anchored>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default Sections;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The in-page nav.
|
|
5
|
+
*
|
|
6
|
+
* Sticky, because the sections below it are long and the whole point is to
|
|
7
|
+
* let somebody who landed from a search engine jump to the part they came
|
|
8
|
+
* for - a timetable, usually - without scrolling past a booking wizard they
|
|
9
|
+
* have not decided to use yet.
|
|
10
|
+
*/
|
|
11
|
+
export const Nav = styled.nav`
|
|
12
|
+
position: sticky;
|
|
13
|
+
top: 0;
|
|
14
|
+
z-index: 20;
|
|
15
|
+
display: flex;
|
|
16
|
+
gap: 6px;
|
|
17
|
+
overflow-x: auto;
|
|
18
|
+
margin-top: 25px;
|
|
19
|
+
padding: 8px 10px;
|
|
20
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
21
|
+
background: ${ props => props.theme.background.normal };
|
|
22
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, .06);
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export const Anchor = styled.a`
|
|
26
|
+
padding: 6px 14px;
|
|
27
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
28
|
+
font-size: ${ props => props.theme.size.s };
|
|
29
|
+
color: ${ props => props.theme.font.normal };
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
|
|
32
|
+
&:hover {
|
|
33
|
+
text-decoration: none;
|
|
34
|
+
background: ${ props => props.theme.background.neutral };
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Anchored sections need somewhere to land that is not under the sticky bar
|
|
40
|
+
* above them.
|
|
41
|
+
*/
|
|
42
|
+
export const Anchored = styled.div`
|
|
43
|
+
scroll-margin-top: 60px;
|
|
44
|
+
`;
|