@autobusal/routes-order 1.13.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 +142 -0
- package/Facts/Facts.tsx +119 -0
- package/Facts/services.ts +19 -0
- package/Facts/styles.ts +48 -0
- package/Facts/types.ts +60 -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/RoutesOrder.tsx +27 -0
- package/Schedule/Schedule.tsx +110 -0
- package/Schedule/styles.ts +53 -0
- package/Sections/Sections.tsx +87 -0
- package/Sections/styles.ts +44 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,148 @@ 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
|
+
|
|
101
|
+
## 1.16.0
|
|
102
|
+
|
|
103
|
+
The crawlable timetable, and a section layout around the wizard.
|
|
104
|
+
|
|
105
|
+
Every service on the pair as a real `<table>` — departs, arrives, duration,
|
|
106
|
+
stops, operator with its rating, price — sorted by departure time. This is the
|
|
107
|
+
part of Tier 3 that actually ranks: dense, unique to the pair, and made of the
|
|
108
|
+
words somebody types, without a sentence of written copy. No interactivity, so
|
|
109
|
+
a prerendered snapshot shows a non-JS crawler exactly what a reader sees.
|
|
110
|
+
|
|
111
|
+
A sticky in-page nav (Trips · Facts · Timetable) so somebody who landed from a
|
|
112
|
+
search engine can reach the timetable without scrolling past a booking form
|
|
113
|
+
they have not decided to use. The wizard itself is untouched — this is a
|
|
114
|
+
layout around it, not a change to the funnel.
|
|
115
|
+
|
|
116
|
+
Facts and the timetable share ONE request. Two components each fetching the
|
|
117
|
+
same response would have doubled what the prerender crawl costs on every pair.
|
|
118
|
+
|
|
119
|
+
No "type" or "class" column, which the roadmap asked for: every route in this
|
|
120
|
+
system is typed `international` and the bus names are inventory identifiers
|
|
121
|
+
like "A100". A column that says nothing on every row reads as padding.
|
|
122
|
+
|
|
123
|
+
## 1.15.0
|
|
124
|
+
|
|
125
|
+
Distance is now measured along the route's own stops rather than as one line
|
|
126
|
+
from origin to destination, and the facts payload carries two numbers instead
|
|
127
|
+
of one: `road`, real driving distance from a routing service, and `approx`,
|
|
128
|
+
the straight lines summed stop by stop.
|
|
129
|
+
|
|
130
|
+
Real road distance prints plainly. The approximation prints with a caveat,
|
|
131
|
+
because it understates every journey — 309 km against 401 km of actual tarmac
|
|
132
|
+
on Tirana–Thessaloniki via Korça, a 23% shortfall. A straight line is the
|
|
133
|
+
shortest path there is, so no number of extra points can ever close that gap.
|
|
134
|
+
|
|
135
|
+
## 1.14.0
|
|
136
|
+
|
|
137
|
+
The facts block on a route-pair page: fares from, journey time, first and last
|
|
138
|
+
departure, how many run, distance and who operates it.
|
|
139
|
+
|
|
140
|
+
Read off the timetable rather than a dated search, so it does not change with
|
|
141
|
+
the date in the URL — a "from EUR 12" that moved depending on which day a
|
|
142
|
+
crawler arrived would look unstable to a search engine and dishonest to a
|
|
143
|
+
reader who came back.
|
|
144
|
+
|
|
145
|
+
Distance is labelled "straight line" because that is what it is: we hold city
|
|
146
|
+
coordinates, not routing data, so the figure is always shorter than the road.
|
|
147
|
+
|
|
148
|
+
Renders nothing at all for a pair with no facts. An empty facts block is worse
|
|
149
|
+
than none — it is the thin content this tier exists to remove.
|
|
150
|
+
|
|
9
151
|
## 1.13.0
|
|
10
152
|
|
|
11
153
|
The operator's rating on each result card. The operator's rather than the
|
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,60 @@
|
|
|
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 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
|
+
|
|
53
|
+
export interface FactsResponse {
|
|
54
|
+
// null for a pair that is not sold - an envelope rather than a bare null,
|
|
55
|
+
// because `{}` is truthy in JavaScript
|
|
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[] }
|
|
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
|