@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 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
@@ -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
+ );
@@ -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
+ }
@@ -2,9 +2,10 @@ import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
3
  import { AiOutlineCaretLeft, AiOutlineCaretRight } from 'react-icons/ai';
4
4
  import { Button } from '@autobusal/common';
5
- import { createDate, getFormattedShort } from '@autobusal/utilities';
5
+ import { useMediaQuery } from '@autobusal/hooks';
6
+ import { createDate, getDayParts } from '@autobusal/utilities';
6
7
  import Loading from './Loading';
7
- import { Container, Inner, ButtonDay, DayPrice, Cheapest } from './styles';
8
+ import { Container, Inner, ButtonDay, DayPrice, Cheapest, Weekday, DayNumber, Month } from './styles';
8
9
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
9
10
  import { FoundDay } from '../../types';
10
11
  import { useGetDates } from '../../services';
@@ -19,6 +20,24 @@ interface Props {
19
20
  const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
20
21
  const [number, setNumber] = useState<number>(0);
21
22
 
23
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
24
+ // Three days on a phone, five on a tablet, seven on a desktop.
25
+ //
26
+ // Seven fit on a phone without a scroller once the labels were trimmed,
27
+ // but only at about 36px a cell - enough to read, not enough to be
28
+ // comfortable to hit with a thumb, and with the price wrapping to three
29
+ // lines underneath. Each step up in width buys two more days rather than
30
+ // squeezing the same seven.
31
+ //
32
+ // This is one of the few things CSS genuinely cannot do: hiding cells
33
+ // would mean hiding FIXED positions, and the days that matter least are
34
+ // the ones furthest from the selected date, which moves.
35
+ const phone = useMediaQuery('(max-width: 639px)');
36
+
37
+ const tablet = useMediaQuery('(max-width: 1023px)');
38
+
39
+ const visible = phone ? 3 : (tablet ? 5 : 7);
40
+
22
41
  const { data, isLoading } = useGetDates(step1, type, number);
23
42
 
24
43
  if (isLoading) {
@@ -67,7 +86,34 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
67
86
  && Math.max(...prices) > (lowest as number)
68
87
  && atLowest * 2 <= prices.length;
69
88
 
70
- const items = data?.map(item => {
89
+ /**
90
+ * The days actually shown.
91
+ *
92
+ * Centred on the selected day where the window allows, so shrinking to
93
+ * five never hides the day somebody is looking at - and clamped at both
94
+ * ends, so a selection sitting at the very start or end of the week still
95
+ * yields five days rather than three.
96
+ */
97
+ const shown = (() => {
98
+ const days = data ?? [];
99
+
100
+ if (days.length <= visible) {
101
+ return days;
102
+ }
103
+
104
+ const selected = days.findIndex(item => item.day === step1[type]);
105
+
106
+ // Centre on the selected day, then clamp - so a selection at either end
107
+ // of the week still yields a full window rather than a short one.
108
+ const start = Math.min(
109
+ Math.max(0, (selected === -1 ? 0 : selected) - Math.floor(visible / 2)),
110
+ days.length - visible
111
+ );
112
+
113
+ return days.slice(start, start + visible);
114
+ })();
115
+
116
+ const items = shown.map(item => {
71
117
  const date = createDate(item.day);
72
118
 
73
119
  // if date in the future, and we have routes available
@@ -77,7 +123,15 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
77
123
 
78
124
  return (
79
125
  <ButtonDay key={ item.id } $available={ isAvailable } $selected={ step1[type] === item.day } onClick={ () => onClick(item, isAvailable) }>
80
- { getFormattedShort(date, t) }
126
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
127
+ In parts, so a phone can drop the month and fit seven days
128
+ without a horizontal scroller - see Month in styles. */ }
129
+ <Weekday>{ getDayParts(date, t).weekday }</Weekday>
130
+
131
+ <DayNumber>
132
+ { getDayParts(date, t).day }
133
+ <Month>{ ' ' + getDayParts(date, t).month }</Month>
134
+ </DayNumber>
81
135
 
82
136
  { isAvailable && item.from_price_display && (
83
137
  <DayPrice $cheapest={ isCheapest }>{ item.from_price_display }</DayPrice>
@@ -88,7 +142,7 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
88
142
  ) }
89
143
  </ButtonDay>
90
144
  );
91
- })
145
+ });
92
146
 
93
147
  return (
94
148
  <Container>
@@ -1,23 +1,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: 10px 15px;
5
+ padding: 8px 6px;
7
6
  background-color: ${ props => props.theme.background.neutral };
8
7
  border-radius: ${ props => props.theme.borderRadius };
8
+
9
+ @media (min-width: 640px) {
10
+ 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: 5px;
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.s };
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
- font-size: ${ props => props.theme.size.xs };
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