@autobusal/routes-order 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +731 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +95 -5
  14. package/Found/Dates/styles.ts +173 -9
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +194 -0
  18. package/Found/Filters/styles.ts +188 -0
  19. package/Found/Found.tsx +207 -17
  20. package/Found/Group/Group.tsx +68 -0
  21. package/Found/Group/styles.ts +44 -0
  22. package/Found/Header/styles.ts +20 -4
  23. package/Found/Orders/Orders.tsx +89 -27
  24. package/Found/Orders/styles.ts +14 -0
  25. package/Found/Picks/Picks.tsx +75 -0
  26. package/Found/Picks/styles.ts +106 -0
  27. package/Found/Route/Route.tsx +211 -33
  28. package/Found/Route/styles.ts +491 -46
  29. package/Found/SameDay/SameDay.tsx +35 -0
  30. package/Found/SameDay/styles.ts +27 -0
  31. package/Found/Sort/Sort.tsx +40 -0
  32. package/Found/Sort/styles.ts +87 -0
  33. package/Found/Summary/Summary.tsx +49 -0
  34. package/Found/Summary/styles.ts +42 -0
  35. package/Found/Watch/Watch.tsx +187 -0
  36. package/Found/Watch/styles.ts +118 -0
  37. package/Found/refine.ts +652 -0
  38. package/Links/Links.tsx +91 -0
  39. package/Links/styles.ts +75 -0
  40. package/RoutesOrder.tsx +239 -31
  41. package/Schedule/Schedule.tsx +123 -0
  42. package/Schedule/styles.ts +71 -0
  43. package/Sections/Schema.tsx +186 -0
  44. package/Sections/Sections.tsx +160 -0
  45. package/Sections/styles.ts +44 -0
  46. package/Step1/prepare.ts +21 -2
  47. package/Step2.tsx +34 -1
  48. package/Step4/Passenger/Passenger.tsx +253 -24
  49. package/Step4/Passenger/styles.ts +21 -1
  50. package/Step4/Passengers/Display.tsx +10 -2
  51. package/Step4/Passengers/Passengers.tsx +53 -4
  52. package/Step4/Saved/Choose.tsx +99 -0
  53. package/Step4/Saved/Remember.tsx +58 -0
  54. package/Step4/Saved/styles.ts +87 -0
  55. package/Step4/checkAge.ts +15 -4
  56. package/Step5/Addons/Addons.tsx +146 -4
  57. package/Step5/Addons/styles.ts +94 -0
  58. package/Step5/Billing/Billing.tsx +138 -70
  59. package/Step5/Coupons/Coupons.tsx +28 -18
  60. package/Step5/Coupons/styles.ts +41 -8
  61. package/Step5/Payments/Payments.tsx +36 -5
  62. package/Step5/Payments/styles.ts +16 -7
  63. package/Step5/Summary/About/About.tsx +99 -27
  64. package/Step5/Summary/About/styles.ts +118 -1
  65. package/Step5/Summary/styles.ts +12 -5
  66. package/Step5/utilities.ts +67 -12
  67. package/Steps/Steps.tsx +3 -1
  68. package/commerce.ts +24 -0
  69. package/package.json +1 -1
  70. package/services.ts +302 -13
  71. package/styles.ts +126 -0
  72. package/types.ts +84 -1
  73. package/Found/Booking/Booking.tsx +0 -61
  74. package/Found/Booking/styles.ts +0 -50
  75. package/Step4/Step4.tsx +0 -114
  76. package/Step5/Step5.tsx +0 -185
@@ -0,0 +1,112 @@
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
+ `;
49
+
50
+ /**
51
+ * The readable half of the facts block.
52
+ *
53
+ * Edited: Claude - Date: 2026-08-20
54
+ *
55
+ * The grid above answers "what are the numbers"; this answers "what is this
56
+ * journey like" in sentences, which is what a search engine quotes and what
57
+ * a reader actually takes away. Sized as body copy rather than as the faded
58
+ * footnote the operators line used to be on its own - it is content now, not
59
+ * an attribution.
60
+ */
61
+ export const Prose = styled.div`
62
+ display: flex;
63
+ flex-direction: column;
64
+ gap: 8px;
65
+ margin-top: 20px;
66
+
67
+ /* One rule for the spacing, so the paragraphs below - which include the
68
+ operators line and its own older margin - cannot each have an opinion
69
+ about the gap between them. */
70
+ p {
71
+ margin: 0;
72
+ line-height: 1.6;
73
+ }
74
+ `;
75
+
76
+ /**
77
+ * The pair is published but nothing on it runs.
78
+ *
79
+ * Edited: Claude - Date: 2026-08-21
80
+ *
81
+ * ABOVE the grid and inside the same box, because it changes how every
82
+ * number below it is to be read: those fares and times are what the
83
+ * operator last published, not what is on sale this morning. A banner
84
+ * floating outside the block would leave the grid looking like a live
85
+ * offer with a disclaimer somewhere else on the page.
86
+ *
87
+ * Informational, not an error - `font.info` rather than `font.error`. A
88
+ * seasonal coach is a normal thing that has happened, and dressing it as a
89
+ * fault would push a reader to leave rather than to look at the routes that
90
+ * ARE running a few hundred pixels down.
91
+ */
92
+ export const Notice = styled.div`
93
+ margin: 0 0 22px;
94
+ padding: 14px 16px;
95
+ border-left: 3px solid ${ props => props.theme.font.info };
96
+ border-radius: ${ props => props.theme.borderRadius };
97
+ background: ${ props => props.theme.background.neutral };
98
+ line-height: 1.6;
99
+
100
+ p {
101
+ margin: 0;
102
+ }
103
+
104
+ /* The headline claim, in the reader's weight rather than a heading level:
105
+ the block already owns an <h2> and a second one here would put "not
106
+ currently scheduled" into the document outline above the facts it
107
+ qualifies. */
108
+ strong {
109
+ display: block;
110
+ font-weight: 700;
111
+ }
112
+ `;
package/Facts/types.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Edited: Claude - Date: 2026-08-20
3
+ *
4
+ * A list of things that are true of the pair, plus whether they are true of
5
+ * EVERY service on it. Buses on one route are not interchangeable - six of
6
+ * the ten Tirana-Thessaloniki coaches are non-stop and they carry different
7
+ * equipment - so "the bus has Wi-Fi" and "one of these buses has Wi-Fi" are
8
+ * different claims and the copy has to be able to tell them apart.
9
+ */
10
+ export interface FactsList {
11
+ every: boolean
12
+ }
13
+
14
+ export interface FactsData {
15
+ from: string
16
+ to: string
17
+ // Edited: Claude - Date: 2026-08-20
18
+ // Names only, and null for a city whose country is not recorded. Lets the
19
+ // page say whether this journey crosses a border, which changes what a
20
+ // traveller has to bring.
21
+ from_country: string | null
22
+ to_country: string | null
23
+ // cheapest adult fare anybody sells this leg for
24
+ price_from: number | null
25
+ // formatted by the API with the brand's own currency - the PUBLIC settings
26
+ // payload carries no currency, so a frontend has nothing to format with
27
+ price_from_display: string | null
28
+ // Edited: Claude - Date: 2026-08-20
29
+ // The dearest fare on the pair, so "how much is a ticket" can answer with
30
+ // a range. Equal to price_from where every service costs the same, and
31
+ // the copy collapses to the single-price sentence rather than printing
32
+ // "from X to X".
33
+ price_to: number | null
34
+ price_to_display: string | null
35
+ // ISO 4217, for schema.org's Offer - the display string has the symbol
36
+ // baked in and guessing a code back out of it would be confidently wrong
37
+ currency: string | null
38
+ // both ends of the range, in minutes
39
+ duration_min: number | null
40
+ duration_max: number | null
41
+ departure_first: string | null
42
+ departure_last: string | null
43
+ // how many coaches serve the pair in the TIMETABLE, not on a given date
44
+ departures: number
45
+ operators: string[]
46
+ /**
47
+ * Edited: Claude - Date: 2026-08-20
48
+ *
49
+ * How often, which `departures` cannot say - ten services means something
50
+ * very different once a week than it does every day.
51
+ *
52
+ * `days` is the UNION of the days a traveller can leave on, ISO 1 Monday
53
+ * to 7 Sunday. `weekly` is the SUM across services, so three daily coaches
54
+ * is 21 rather than 7. Neither is recoverable from the other.
55
+ *
56
+ * NULL when no service on the pair has a usable timetable row - including
57
+ * the case that matters, a season that has already ENDED. A summer coach
58
+ * whose window closed last September does not "run three times a week",
59
+ * and obtapi withholds the whole object rather than counting it.
60
+ */
61
+ frequency: { days: number[], weekly: number, daily: boolean } | null
62
+ /**
63
+ * Edited: Claude - Date: 2026-08-21
64
+ *
65
+ * WHETHER ANY OF THIS IS STILL ON OFFER. A route is published to the
66
+ * sitemap on a `searchable` flag that nothing re-checks, so a season
67
+ * ending never un-publishes the page - and the page had no way to know.
68
+ * It rendered a facts block quietly missing its frequency and a departure
69
+ * list that came back empty for every date the visitor tried.
70
+ *
71
+ * `scheduled` false means NO leg on this pair has a timetable window that
72
+ * is still open, which is the same test the search applies - so nothing
73
+ * can be bought here on any date. `last` is then the day the last of them
74
+ * ran, or NULL when no leg ever had a window at all (a route published
75
+ * before anybody gave it a timetable). Those two want different
76
+ * sentences: one has a date to name and the other does not, and inventing
77
+ * one would be a lie.
78
+ *
79
+ * `last` is deliberately null while `scheduled` is true - there it would
80
+ * be some other leg's season ending, printed where a reader reads "the
81
+ * last day this ran".
82
+ *
83
+ * OPTIONAL, and that is not laziness: obtapi caches this payload for six
84
+ * hours, so for six hours after a deploy the field is simply absent from
85
+ * warm entries. Absent means WE DID NOT COMPUTE IT and the page says
86
+ * nothing - which is also the honest reading of `frequency: null` with
87
+ * `scheduled: true`, a pair whose windows are open but whose operators
88
+ * never listed their days. "We cannot say how often" and "it does not
89
+ * run" are different claims and only the second one gets the notice.
90
+ */
91
+ service?: { scheduled: boolean, last: string | null }
92
+ // how many of the `departures` run non-stop, and how many are still
93
+ // rolling the next morning - counted out of the total rather than reported
94
+ // as a yes/no, because "one of ten is direct" is the honest version
95
+ direct: number
96
+ overnight: number
97
+ // the cities in between, across every service on the pair - `every` says
98
+ // whether all of them call at all of these, which on a busy pair they
99
+ // usually do not
100
+ via: (FactsList & { cities: string[] }) | null
101
+ // what is on board, resolved to display names by obtapi (the ids are
102
+ // inventory keys and the names come from its lang files)
103
+ amenities: (FactsList & { names: string[] }) | null
104
+ // Edited: Claude - Date: 2026-08-20
105
+ // The pair's own review score - the published reviews of every route in
106
+ // this very timetable, aggregated by obtapi with the same threshold rule
107
+ // as everything else (null until there is enough to say something honest).
108
+ // For structured data; note ScheduleRow.rating below is the OPERATOR's
109
+ // company-wide number, a different thing.
110
+ rating: { average: number, count: number } | null
111
+ // Two numbers that must never be conflated.
112
+ //
113
+ // `road` is real driving distance from a routing service and is null unless
114
+ // one is configured. `approx` sums straight lines stop by stop - always
115
+ // available, and ALWAYS SHORT, because a straight line is the shortest path
116
+ // there is. Measured on Tirana-Thessaloniki via Korca: approx 309 km
117
+ // against 401 km of actual road, a 23% understatement. Show `road` plainly
118
+ // when it exists; show `approx` only with its caveat attached.
119
+ distance: { road: number | null, approx: number } | null
120
+ // every service on the pair, sorted by departure - Tier 3.3
121
+ schedule: ScheduleRow[]
122
+ }
123
+
124
+ export interface ScheduleRow {
125
+ route: string
126
+ operator: string | null
127
+ // null until the operator has enough published reviews - see Tier 2
128
+ rating: { average: number, count: number } | null
129
+ departure: string | null
130
+ arrival: string | null
131
+ /**
132
+ * Edited: Ferjolt Ozuni - Date: 2026-08-05
133
+ * Days between boarding and arriving - 0 same day, 1 the next morning.
134
+ * A timetable of two clock times per row cannot show an overnight service
135
+ * without it.
136
+ */
137
+ offset?: number
138
+ minutes: number | null
139
+ // 0 is a direct service
140
+ stops: number
141
+ price: string
142
+ }
143
+
144
+ export interface LinkRow {
145
+ from: string
146
+ to: string
147
+ from_name: string
148
+ to_name: string
149
+ url: string
150
+ // paid orders in the last year - ranks the list, never gates membership
151
+ orders: number
152
+ }
153
+
154
+ export interface FactsResponse {
155
+ // null for a pair that is not sold - an envelope rather than a bare null,
156
+ // because `{}` is truthy in JavaScript
157
+ facts: FactsData | null
158
+ // Tier 3.4 - pairs leaving the origin, and pairs arriving at the
159
+ // destination. Present even when `facts` is null.
160
+ //
161
+ // Edited: Claude - Date: 2026-08-20
162
+ // `reverse` is the journey home. Pairs are directional, so it is a
163
+ // different page with its own timetable and fares - and it belongs to
164
+ // neither of the two blocks above, being a pair from the DESTINATION back
165
+ // to the origin. Null when the return leg is not sold.
166
+ links: { from: LinkRow[], to: LinkRow[], reverse: LinkRow | null }
167
+ }
package/Faq/Faq.tsx ADDED
@@ -0,0 +1,52 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Container, Heading, List, Item, Question, Answer } from './styles';
3
+ import { Question as QuestionData } from '../Facts/questions';
4
+
5
+ interface Props {
6
+ id: string
7
+ from: string
8
+ to: string
9
+ // Edited: Claude - Date: 2026-08-20
10
+ // Handed in already built, from Facts/questions - the same array the
11
+ // FAQPage structured data is emitted from. Building them here as well
12
+ // would give the page two lists that agree today and drift the first time
13
+ // one of them is edited, and the whole value of an FAQ rich result is that
14
+ // the answer Google quotes is the answer the reader finds.
15
+ questions: QuestionData[]
16
+ t: TFunction<'common'>
17
+ }
18
+
19
+ /**
20
+ * The questions this pair page answers, for a human.
21
+ *
22
+ * Edited: Claude - Date: 2026-08-20
23
+ *
24
+ * Every answer is a fact obtapi computed off the timetable - how long, how
25
+ * much, how often, who runs it, where it stops - so a pair served by one
26
+ * coach a week gets a short honest section rather than a long padded one.
27
+ * Nothing is asked that the data cannot answer, which is why this renders
28
+ * whatever it is handed and never composes a fallback.
29
+ */
30
+ const Faq = ({ id, from, to, questions, t }: Props): (JSX.Element | null) => {
31
+ if (questions.length === 0) {
32
+ return null;
33
+ }
34
+
35
+ return (
36
+ <Container id={ id } className="box">
37
+ <Heading>{ t('routes_order.faq.title', { from, to }) }</Heading>
38
+
39
+ <List>
40
+ { questions.map(item => (
41
+ <Item key={ item.id }>
42
+ <Question>{ item.q }</Question>
43
+
44
+ <Answer>{ item.a }</Answer>
45
+ </Item>
46
+ )) }
47
+ </List>
48
+ </Container>
49
+ );
50
+ };
51
+
52
+ export default Faq;
package/Faq/styles.ts ADDED
@@ -0,0 +1,68 @@
1
+ import styled from 'styled-components';
2
+
3
+ export const Container = styled.section`
4
+ margin-top: 25px;
5
+ `;
6
+
7
+ export const Heading = styled.h2`
8
+ margin: 0 0 15px;
9
+ `;
10
+
11
+ export const List = styled.div`
12
+ border-top: 1px solid ${ props => props.theme.background.neutral };
13
+ `;
14
+
15
+ /**
16
+ * A native <details>, deliberately.
17
+ *
18
+ * Edited: Claude - Date: 2026-08-20
19
+ *
20
+ * No JavaScript and no state: the answer is in the DOM whether it is open or
21
+ * shut, which is the only property that matters here. This page is snapshot
22
+ * by the prerender crawl and read by machines that do not click, and an
23
+ * accordion built out of `useState` would have shipped them a list of
24
+ * questions with the answers missing - while the FAQPage schema alongside
25
+ * promised those very answers, which is precisely the mismatch that costs a
26
+ * site its rich results.
27
+ */
28
+ export const Item = styled.details`
29
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
30
+
31
+ &[open] summary {
32
+ font-weight: 700;
33
+ }
34
+ `;
35
+
36
+ export const Question = styled.summary`
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: space-between;
40
+ gap: 15px;
41
+ padding: 14px 0;
42
+ cursor: pointer;
43
+ font-size: ${ props => props.theme.size.m };
44
+ list-style: none;
45
+
46
+ /* the default disclosure triangle, replaced below by one that turns */
47
+ &::-webkit-details-marker {
48
+ display: none;
49
+ }
50
+
51
+ &:after {
52
+ content: '+';
53
+ flex: 0 0 auto;
54
+ color: ${ props => props.theme.font.faded };
55
+ font-weight: 400;
56
+ }
57
+
58
+ [open] > &:after {
59
+ content: '–';
60
+ }
61
+ `;
62
+
63
+ export const Answer = styled.p`
64
+ margin: 0;
65
+ padding: 0 0 16px;
66
+ color: ${ props => props.theme.font.faded };
67
+ line-height: 1.6;
68
+ `;
@@ -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 } 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) {
@@ -43,16 +62,87 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
43
62
 
44
63
  const today = new Date();
45
64
 
46
- const items = data?.map(item => {
65
+ /**
66
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
67
+ *
68
+ * The cheapest fare in the visible window, so it can be called out.
69
+ *
70
+ * Two guards, both learned by looking at it: the badge is pointless when
71
+ * every day costs the same, and it is worse than pointless when most days
72
+ * tie at the minimum - badging six of seven days trains people to ignore
73
+ * the badge entirely. It only appears when the cheap days are genuinely
74
+ * the minority, which is when shifting a date is actually worth telling
75
+ * somebody about.
76
+ */
77
+ const prices = (data ?? [])
78
+ .filter(item => typeof item.from_price === 'number')
79
+ .map(item => item.from_price as number);
80
+
81
+ const lowest = prices.length > 0 ? Math.min(...prices) : null;
82
+
83
+ const atLowest = prices.filter(price => price === lowest).length;
84
+
85
+ const varies = prices.length > 1
86
+ && Math.max(...prices) > (lowest as number)
87
+ && atLowest * 2 <= prices.length;
88
+
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 => {
47
117
  const date = createDate(item.day);
48
118
 
49
119
  // if date in the future, and we have routes available
50
120
  const isAvailable = today.getTime() <= date.getTime() && item.routes;
51
121
 
122
+ const isCheapest = varies && lowest !== null && item.from_price === lowest;
123
+
52
124
  return (
53
- <ButtonDay key={ item.id } $available={ isAvailable } $selected={ step1[type] === item.day } onClick={ () => onClick(item, isAvailable) }>{ getFormattedShort(date, t) }</ButtonDay>
125
+ <ButtonDay key={ item.id } $available={ isAvailable } $selected={ step1[type] === item.day } onClick={ () => onClick(item, isAvailable) }>
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>
135
+
136
+ { isAvailable && item.from_price_display && (
137
+ <DayPrice $cheapest={ isCheapest }>{ item.from_price_display }</DayPrice>
138
+ ) }
139
+
140
+ { isCheapest && isAvailable && (
141
+ <Cheapest $selected={ step1[type] === item.day }>{ t('routes_order.step2.dates.cheapest') }</Cheapest>
142
+ ) }
143
+ </ButtonDay>
54
144
  );
55
- })
145
+ });
56
146
 
57
147
  return (
58
148
  <Container>