@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.
- package/CHANGELOG.md +731 -0
- package/Checkout/Checkout.tsx +519 -0
- package/Checkout/Sidebar/Sidebar.tsx +124 -0
- package/Checkout/Sidebar/styles.ts +52 -0
- package/Facts/Facts.tsx +271 -0
- package/Facts/questions.ts +250 -0
- package/Facts/service.ts +111 -0
- package/Facts/services.ts +19 -0
- package/Facts/styles.ts +112 -0
- package/Facts/types.ts +167 -0
- package/Faq/Faq.tsx +52 -0
- package/Faq/styles.ts +68 -0
- package/Found/Dates/Dates.tsx +95 -5
- package/Found/Dates/styles.ts +173 -9
- package/Found/Empty/Empty.tsx +136 -0
- package/Found/Empty/styles.ts +98 -0
- package/Found/Filters/Filters.tsx +194 -0
- package/Found/Filters/styles.ts +188 -0
- package/Found/Found.tsx +207 -17
- package/Found/Group/Group.tsx +68 -0
- package/Found/Group/styles.ts +44 -0
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +89 -27
- package/Found/Orders/styles.ts +14 -0
- package/Found/Picks/Picks.tsx +75 -0
- package/Found/Picks/styles.ts +106 -0
- package/Found/Route/Route.tsx +211 -33
- package/Found/Route/styles.ts +491 -46
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/Sort.tsx +40 -0
- package/Found/Sort/styles.ts +87 -0
- package/Found/Summary/Summary.tsx +49 -0
- package/Found/Summary/styles.ts +42 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/Found/refine.ts +652 -0
- package/Links/Links.tsx +91 -0
- package/Links/styles.ts +75 -0
- package/RoutesOrder.tsx +239 -31
- package/Schedule/Schedule.tsx +123 -0
- package/Schedule/styles.ts +71 -0
- package/Sections/Schema.tsx +186 -0
- package/Sections/Sections.tsx +160 -0
- package/Sections/styles.ts +44 -0
- package/Step1/prepare.ts +21 -2
- package/Step2.tsx +34 -1
- package/Step4/Passenger/Passenger.tsx +253 -24
- package/Step4/Passenger/styles.ts +21 -1
- package/Step4/Passengers/Display.tsx +10 -2
- package/Step4/Passengers/Passengers.tsx +53 -4
- package/Step4/Saved/Choose.tsx +99 -0
- package/Step4/Saved/Remember.tsx +58 -0
- package/Step4/Saved/styles.ts +87 -0
- package/Step4/checkAge.ts +15 -4
- package/Step5/Addons/Addons.tsx +146 -4
- package/Step5/Addons/styles.ts +94 -0
- package/Step5/Billing/Billing.tsx +138 -70
- package/Step5/Coupons/Coupons.tsx +28 -18
- package/Step5/Coupons/styles.ts +41 -8
- package/Step5/Payments/Payments.tsx +36 -5
- package/Step5/Payments/styles.ts +16 -7
- package/Step5/Summary/About/About.tsx +99 -27
- package/Step5/Summary/About/styles.ts +118 -1
- package/Step5/Summary/styles.ts +12 -5
- package/Step5/utilities.ts +67 -12
- package/Steps/Steps.tsx +3 -1
- package/commerce.ts +24 -0
- package/package.json +1 -1
- package/services.ts +302 -13
- package/styles.ts +126 -0
- package/types.ts +84 -1
- package/Found/Booking/Booking.tsx +0 -61
- package/Found/Booking/styles.ts +0 -50
- package/Step4/Step4.tsx +0 -114
- package/Step5/Step5.tsx +0 -185
package/Facts/styles.ts
ADDED
|
@@ -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
|
+
`;
|
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 } 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
|
-
|
|
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) }>
|
|
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>
|