@autobusal/routes-order 1.37.4 → 1.37.6
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/Availability/Availability.tsx +166 -0
- package/Availability/Schema.tsx +109 -0
- package/Availability/service.ts +368 -0
- package/Availability/services.ts +48 -0
- package/Availability/styles.ts +282 -0
- package/Availability/types.ts +91 -0
- package/Facts/questions.ts +39 -8
- package/Facts/service.ts +8 -1
- package/Found/Route/Route.tsx +47 -1
- package/Found/Route/styles.ts +43 -1
- package/Sections/Sections.tsx +57 -0
- package/package.json +1 -1
|
@@ -0,0 +1,282 @@
|
|
|
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 8px;
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One sentence saying what the table underneath is.
|
|
13
|
+
*
|
|
14
|
+
* Edited: Claude - Date: 2026-09-16
|
|
15
|
+
*
|
|
16
|
+
* Written for the reader who arrived asking whether there is a bus on a
|
|
17
|
+
* particular day - and, just as much, for the assistant answering that
|
|
18
|
+
* question on their behalf. A table of numbers with no sentence around it
|
|
19
|
+
* is perfectly readable to a person looking at it and gives a machine
|
|
20
|
+
* quoting the page nothing to quote.
|
|
21
|
+
*/
|
|
22
|
+
export const Lead = styled.p`
|
|
23
|
+
margin: 0 0 15px;
|
|
24
|
+
color: ${ props => props.theme.font.faded };
|
|
25
|
+
line-height: 1.6;
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sideways scrolling is confined to the table, never the page.
|
|
30
|
+
*/
|
|
31
|
+
export const Wrap = styled.div`
|
|
32
|
+
overflow-x: auto;
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A real table, for the same reason the timetable above is one: this is
|
|
37
|
+
* tabular data, and the structure that makes it readable is the structure
|
|
38
|
+
* that lets a crawler understand the columns.
|
|
39
|
+
*
|
|
40
|
+
* Edited: Claude - Date: 2026-09-16
|
|
41
|
+
*
|
|
42
|
+
* Grouped by date, with the date as a row heading spanning the width -
|
|
43
|
+
* thirty separate tables would repeat the column headers thirty times, and
|
|
44
|
+
* a flat list of two hundred rows with the date in a seventh column is
|
|
45
|
+
* unreadable at exactly the moment somebody is scanning for one day.
|
|
46
|
+
*
|
|
47
|
+
* NO INTERACTIVITY ANYWHERE IN IT. A prerendered snapshot shows a
|
|
48
|
+
* non-JavaScript crawler precisely what a reader sees.
|
|
49
|
+
*/
|
|
50
|
+
export const Table = styled.table`
|
|
51
|
+
width: 100%;
|
|
52
|
+
min-width: 620px;
|
|
53
|
+
border-collapse: collapse;
|
|
54
|
+
|
|
55
|
+
th, td {
|
|
56
|
+
padding: 10px 12px;
|
|
57
|
+
text-align: left;
|
|
58
|
+
border-bottom: 1px solid ${ props => props.theme.background.neutral };
|
|
59
|
+
white-space: nowrap;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
thead th {
|
|
63
|
+
font-size: ${ props => props.theme.size.s };
|
|
64
|
+
color: ${ props => props.theme.font.faded };
|
|
65
|
+
font-weight: 400;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* The date, as a heading row. Sticky-looking rather than sticky: a
|
|
70
|
+
* position: sticky heading inside a table fights the sticky section nav
|
|
71
|
+
* at the top of the page, and the two overlapping is worse than neither.
|
|
72
|
+
*/
|
|
73
|
+
tbody th {
|
|
74
|
+
padding: 16px 12px 8px;
|
|
75
|
+
border-bottom: 1px solid ${ props => props.theme.background.neutral };
|
|
76
|
+
font-size: ${ props => props.theme.size.s };
|
|
77
|
+
font-weight: 700;
|
|
78
|
+
background: ${ props => props.theme.background.normal };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* a day nothing runs on - stated, never omitted */
|
|
82
|
+
tbody td.none {
|
|
83
|
+
color: ${ props => props.theme.font.faded };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
tbody tr:last-child td {
|
|
87
|
+
border-bottom: none;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
* The same reasoning as the timetable's own narrow-screen rule: six
|
|
92
|
+
* columns need 620px and the phone reading this has 375, so the columns
|
|
93
|
+
* that fall off the right edge would be the ones that matter most.
|
|
94
|
+
*
|
|
95
|
+
* Duration is the one that goes, and it is the right one: it is DERIVED
|
|
96
|
+
* from the departure and arrival already on the row, so a reader loses
|
|
97
|
+
* arithmetic rather than a fact. Everything else stays, including the
|
|
98
|
+
* seat count, which is the whole reason this section exists.
|
|
99
|
+
*/
|
|
100
|
+
@media (max-width: 767px) {
|
|
101
|
+
min-width: 0;
|
|
102
|
+
table-layout: fixed;
|
|
103
|
+
|
|
104
|
+
th:nth-child(3), td:nth-child(3) {
|
|
105
|
+
display: none;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
th, td {
|
|
109
|
+
padding: 8px 4px;
|
|
110
|
+
font-size: ${ props => props.theme.size.xs };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* the times, the fare and the link take what they need; the operator
|
|
114
|
+
takes the remainder and wraps inside it */
|
|
115
|
+
th:nth-child(1), td:nth-child(1),
|
|
116
|
+
th:nth-child(2), td:nth-child(2) {
|
|
117
|
+
width: 17%;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
th:nth-child(5), td:nth-child(5) {
|
|
121
|
+
width: 23%;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
th:nth-child(6), td:nth-child(6) {
|
|
125
|
+
width: 17%;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
th:nth-child(4), td:nth-child(4),
|
|
129
|
+
td.none {
|
|
130
|
+
white-space: normal;
|
|
131
|
+
word-break: break-word;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A clock time that also carries the machine-readable instant.
|
|
138
|
+
*
|
|
139
|
+
* Edited: Claude - Date: 2026-09-16
|
|
140
|
+
*
|
|
141
|
+
* A <time datetime> rather than a <strong>, and the attribute is the very
|
|
142
|
+
* string the BusTrip markup emits - so the page does not merely happen to
|
|
143
|
+
* agree with its own structured data, it renders it.
|
|
144
|
+
*/
|
|
145
|
+
export const Time = styled.time`
|
|
146
|
+
font-weight: 700;
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
export const Faded = styled.span`
|
|
150
|
+
color: ${ props => props.theme.font.faded };
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The fare, with the seat count under it - the arrangement the result
|
|
155
|
+
* cards already use, because how many are left is a fact about this fare on
|
|
156
|
+
* this coach and is read at the same moment as the price.
|
|
157
|
+
*/
|
|
158
|
+
export const Fare = styled.div`
|
|
159
|
+
display: flex;
|
|
160
|
+
flex-direction: column;
|
|
161
|
+
gap: 2px;
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
export const Price = styled.strong`
|
|
165
|
+
font-weight: 700;
|
|
166
|
+
white-space: nowrap;
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* How many seats are still on sale.
|
|
171
|
+
*
|
|
172
|
+
* Edited: Claude - Date: 2026-09-16
|
|
173
|
+
*
|
|
174
|
+
* The same string and the same threshold as the result cards - see
|
|
175
|
+
* SeatsLeft in ../Found/Route/styles and LOW_SEATS in ./service, where the
|
|
176
|
+
* reasoning lives. The component is not shared because the container is
|
|
177
|
+
* not: there it
|
|
178
|
+
* is centred under a fare in a card, here it is left-aligned in a table
|
|
179
|
+
* cell, and a shared component carrying both would be two components in a
|
|
180
|
+
* trench coat.
|
|
181
|
+
*
|
|
182
|
+
* What IS shared is the only thing that matters - the wording and the
|
|
183
|
+
* number - because a coach that reads "nearly full" on the search results
|
|
184
|
+
* and "plenty of room" here is a bug, and one that nobody would notice
|
|
185
|
+
* until a customer did.
|
|
186
|
+
*/
|
|
187
|
+
export const SeatsLeft = styled.span<{ $low: boolean }>`
|
|
188
|
+
font-size: ${ props => props.theme.size.xxs };
|
|
189
|
+
line-height: 1.25;
|
|
190
|
+
white-space: nowrap;
|
|
191
|
+
font-weight: ${ props => (props.$low ? 700 : 400) };
|
|
192
|
+
color: ${ props => (props.$low ? props.theme.font.error : props.theme.font.faded) };
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The booking link for one departure.
|
|
197
|
+
*
|
|
198
|
+
* Edited: Claude - Date: 2026-09-16
|
|
199
|
+
*
|
|
200
|
+
* A plain anchor, not a router Link. The URL is built by obtapi and arrives
|
|
201
|
+
* absolute, carrying the date, the direction and its tracking parameters;
|
|
202
|
+
* picking it apart into a router path would mean re-deriving on the client
|
|
203
|
+
* something the server already decided, and getting it subtly wrong on a
|
|
204
|
+
* whitelabel domain. Same tab, because it is our own site and the reader is
|
|
205
|
+
* continuing, not leaving.
|
|
206
|
+
*/
|
|
207
|
+
export const Book = styled.a`
|
|
208
|
+
display: inline-block;
|
|
209
|
+
padding: 6px 12px;
|
|
210
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
211
|
+
background: ${ props => props.theme.primary.neutral };
|
|
212
|
+
color: ${ props => props.theme.font.normal };
|
|
213
|
+
font-size: ${ props => props.theme.size.xs };
|
|
214
|
+
font-weight: 700;
|
|
215
|
+
white-space: nowrap;
|
|
216
|
+
|
|
217
|
+
&:hover {
|
|
218
|
+
text-decoration: none;
|
|
219
|
+
background: ${ props => props.theme.background.neutral };
|
|
220
|
+
}
|
|
221
|
+
`;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Everything past the first week.
|
|
225
|
+
*
|
|
226
|
+
* Edited: Claude - Date: 2026-09-16
|
|
227
|
+
*
|
|
228
|
+
* A native <details>, for the one property that decides it: THE ROWS ARE IN
|
|
229
|
+
* THE DOM WHETHER IT IS OPEN OR SHUT. This page is snapshot by the
|
|
230
|
+
* prerender crawl and read by machines that do not click, and the whole
|
|
231
|
+
* point of the section is that all thirty days are visible to them. An
|
|
232
|
+
* accordion built out of useState would ship a crawler seven days and a
|
|
233
|
+
* button - which is exactly how the homepage FAQ came to be prerendered
|
|
234
|
+
* with zero answers in it - while the BusTrip markup alongside promised
|
|
235
|
+
* thirty days of departures.
|
|
236
|
+
*
|
|
237
|
+
* Collapsed rather than not, because thirty day-blocks between the
|
|
238
|
+
* timetable and the footer is a page nobody reaches the bottom of.
|
|
239
|
+
*/
|
|
240
|
+
export const Later = styled.details`
|
|
241
|
+
margin-top: 4px;
|
|
242
|
+
|
|
243
|
+
&[open] summary {
|
|
244
|
+
margin-bottom: 4px;
|
|
245
|
+
}
|
|
246
|
+
`;
|
|
247
|
+
|
|
248
|
+
export const More = styled.summary`
|
|
249
|
+
display: inline-flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
gap: 8px;
|
|
252
|
+
padding: 10px 0;
|
|
253
|
+
cursor: pointer;
|
|
254
|
+
font-size: ${ props => props.theme.size.s };
|
|
255
|
+
font-weight: 700;
|
|
256
|
+
list-style: none;
|
|
257
|
+
|
|
258
|
+
/* the default disclosure triangle, replaced below by one that turns */
|
|
259
|
+
&::-webkit-details-marker {
|
|
260
|
+
display: none;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
&:after {
|
|
264
|
+
content: '+';
|
|
265
|
+
color: ${ props => props.theme.font.faded };
|
|
266
|
+
font-weight: 400;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
[open] > &:after {
|
|
270
|
+
content: '-';
|
|
271
|
+
}
|
|
272
|
+
`;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* When the seats and fares above were measured - see `checked` in
|
|
276
|
+
* ./service for why the page says so at all.
|
|
277
|
+
*/
|
|
278
|
+
export const Updated = styled.p`
|
|
279
|
+
margin: 12px 0 0;
|
|
280
|
+
font-size: ${ props => props.theme.size.xs };
|
|
281
|
+
color: ${ props => props.theme.font.faded };
|
|
282
|
+
`;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What /api/routes/search/availability answers with.
|
|
3
|
+
*
|
|
4
|
+
* Edited: Claude - Date: 2026-09-16
|
|
5
|
+
*
|
|
6
|
+
* A SECOND endpoint, deliberately, rather than another branch of
|
|
7
|
+
* /facts. The facts payload is cached for six hours because everything in
|
|
8
|
+
* it - the duration range, the fares-from, the operators - is a property of
|
|
9
|
+
* the TIMETABLE and does not change between two visitors. A seat count is
|
|
10
|
+
* the opposite: it is true at the moment it was measured and false by the
|
|
11
|
+
* afternoon, and folding it into a payload with a six-hour cache would have
|
|
12
|
+
* published stale seat counts as structured data.
|
|
13
|
+
*
|
|
14
|
+
* The nullable fields mirror ScheduleRow in ../Facts/types for the same
|
|
15
|
+
* reason it has them: this arrives over the wire from a search that can
|
|
16
|
+
* legitimately not know an operator name or a formatted fare, and a page
|
|
17
|
+
* that renders "undefined" in a timetable cell is worse than one that
|
|
18
|
+
* renders a dash.
|
|
19
|
+
*/
|
|
20
|
+
export interface AvailabilityDeparture {
|
|
21
|
+
// local clock time at the origin, "08:30"
|
|
22
|
+
departure: string
|
|
23
|
+
arrival: string | null
|
|
24
|
+
/**
|
|
25
|
+
* Days between boarding and arriving - 0 same day, 1 the next morning.
|
|
26
|
+
* Optional because an older build of the endpoint may not send it, and
|
|
27
|
+
* absent has to read as "same day" rather than as a crash.
|
|
28
|
+
*/
|
|
29
|
+
arrival_offset?: number
|
|
30
|
+
duration_min: number | null
|
|
31
|
+
operator: string | null
|
|
32
|
+
// how many intermediate calls - carried for completeness; this section
|
|
33
|
+
// does not show it, and the timetable below already does
|
|
34
|
+
stops: number
|
|
35
|
+
price: number | null
|
|
36
|
+
price_display: string | null
|
|
37
|
+
// null where the search cannot say - see the render, which then shows
|
|
38
|
+
// nothing rather than guessing at a number people act on
|
|
39
|
+
seats_left: number | null
|
|
40
|
+
// the booking URL for THIS departure, already carrying its date and its
|
|
41
|
+
// tracking parameters - built by obtapi, never assembled here
|
|
42
|
+
url: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AvailabilityDay {
|
|
46
|
+
// ISO date, "2026-09-20"
|
|
47
|
+
date: string
|
|
48
|
+
// empty on a day nothing runs, which is information rather than an
|
|
49
|
+
// omission - see Availability.tsx
|
|
50
|
+
departures: AvailabilityDeparture[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AvailabilityData {
|
|
54
|
+
from: string
|
|
55
|
+
to: string
|
|
56
|
+
from_slug: string
|
|
57
|
+
to_slug: string
|
|
58
|
+
// ISO 4217, for the Offer - price_display has the symbol baked in and
|
|
59
|
+
// guessing a code back out of it would be confidently wrong
|
|
60
|
+
currency: string | null
|
|
61
|
+
// IANA zone of the ORIGIN, which is what turns "08:30 on 20 September"
|
|
62
|
+
// into an instant a machine can compare against another operator's
|
|
63
|
+
timezone: string
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The clock the ARRIVAL times are on, which is not the departure city's on
|
|
67
|
+
* exactly the journeys this section exists for.
|
|
68
|
+
*
|
|
69
|
+
* Edited: Claude - Date: 2026-09-16
|
|
70
|
+
*
|
|
71
|
+
* A timetable prints every stop in its own local time. Stamping an arrival
|
|
72
|
+
* with the origin's offset publishes an instant that is wrong by the
|
|
73
|
+
* difference between the two - Thessaloniki to Tirana arrives 16:30 in
|
|
74
|
+
* Tirana, an hour after the +03:00 reading of the same clock face - and in
|
|
75
|
+
* schema.org markup that is a claim an assistant repeats as fact.
|
|
76
|
+
*
|
|
77
|
+
* Optional: an older API does not send it, and the code falls back to the
|
|
78
|
+
* departure zone, which is exactly what it did before this existed.
|
|
79
|
+
*/
|
|
80
|
+
arrival_timezone?: string
|
|
81
|
+
// when the seats and fares below were measured
|
|
82
|
+
generated_at: string
|
|
83
|
+
days: AvailabilityDay[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AvailabilityResponse {
|
|
87
|
+
// an envelope rather than a bare null, matching FactsResponse - `{}` is
|
|
88
|
+
// truthy in JavaScript and a bare null tells a caller nothing about
|
|
89
|
+
// whether the request was even understood
|
|
90
|
+
availability: AvailabilityData | null
|
|
91
|
+
}
|
package/Facts/questions.ts
CHANGED
|
@@ -170,17 +170,40 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
|
|
|
170
170
|
});
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
/**
|
|
174
|
+
* DIRECT MEANS NO CHANGE OF BUS. That is the question being asked.
|
|
175
|
+
*
|
|
176
|
+
* Edited: Claude - Date: 2026-09-16
|
|
177
|
+
*
|
|
178
|
+
* This was answered out of `facts.direct` alone, which counts services
|
|
179
|
+
* with no intermediate STOPS - so it said "No. Every scheduled service
|
|
180
|
+
* calls at stops along the way" on 201 of the 202 published pairs, under
|
|
181
|
+
* a timetable showing a single coach running the whole route. Every bus
|
|
182
|
+
* in the Balkans calls somewhere, so the answer was structurally always
|
|
183
|
+
* no. A traveller reads that as "you have to change buses", and so does
|
|
184
|
+
* an assistant: ChatGPT quoted the contradiction back to us against the
|
|
185
|
+
* Thessaloniki-Tirana page.
|
|
186
|
+
*
|
|
187
|
+
* Nothing in Libraries\Routes\Search ever joins two routes into one
|
|
188
|
+
* journey, so every service that can be booked here is one bus end to
|
|
189
|
+
* end, and yes is not a generous reading - it is the fact.
|
|
190
|
+
*
|
|
191
|
+
* The stop count keeps its own word, NON-STOP, and its own sentence. It
|
|
192
|
+
* is the rarer and more valuable fact, still counted out of the total
|
|
193
|
+
* rather than claimed for the whole pair, but it no longer decides
|
|
194
|
+
* whether the pair has a direct bus at all.
|
|
195
|
+
*/
|
|
175
196
|
if (facts.departures > 0 && !dead) {
|
|
176
197
|
all.push({
|
|
177
198
|
id: 'direct',
|
|
178
199
|
q: t('routes_order.schema.direct_q', cities),
|
|
179
|
-
a: facts.direct ===
|
|
180
|
-
? t('routes_order.schema.
|
|
181
|
-
: (facts.direct
|
|
182
|
-
? t('routes_order.schema.
|
|
183
|
-
:
|
|
200
|
+
a: facts.direct === facts.departures
|
|
201
|
+
? t('routes_order.schema.direct_a_nonstop_all', cities)
|
|
202
|
+
: (facts.direct > 0
|
|
203
|
+
? t('routes_order.schema.direct_a_nonstop_some', { ...cities, count: facts.direct, total: facts.departures })
|
|
204
|
+
: (facts.departures === 1
|
|
205
|
+
? t('routes_order.schema.direct_a_through_single', cities)
|
|
206
|
+
: t('routes_order.schema.direct_a_through', { ...cities, total: facts.departures })))
|
|
184
207
|
});
|
|
185
208
|
}
|
|
186
209
|
|
|
@@ -190,9 +213,17 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
|
|
|
190
213
|
all.push({
|
|
191
214
|
id: 'overnight',
|
|
192
215
|
q: t('routes_order.schema.overnight_q', cities),
|
|
216
|
+
// Edited: Claude - Date: 2026-09-16
|
|
217
|
+
// "1 of the 2 scheduled services ... travel overnight" was published
|
|
218
|
+
// on 40 pairs, in the visible FAQ and in the JSON-LD both. Its own
|
|
219
|
+
// key rather than an i18next plural, for the reason spelled out
|
|
220
|
+
// above departures_a_single: the eight Slavic locales here do not
|
|
221
|
+
// share English's one rule.
|
|
193
222
|
a: facts.overnight === facts.departures
|
|
194
223
|
? t('routes_order.schema.overnight_a_all', cities)
|
|
195
|
-
:
|
|
224
|
+
: (facts.overnight === 1
|
|
225
|
+
? t('routes_order.schema.overnight_a_single', { ...cities, total: facts.departures })
|
|
226
|
+
: t('routes_order.schema.overnight_a', { ...cities, count: facts.overnight, total: facts.departures }))
|
|
196
227
|
});
|
|
197
228
|
}
|
|
198
229
|
|
package/Facts/service.ts
CHANGED
|
@@ -41,8 +41,15 @@ export const stopped = (facts: FactsData): (Stopped | null) => {
|
|
|
41
41
|
* ISO month index to the month names the site already ships in all fifteen
|
|
42
42
|
* languages (`data.months.*`), rather than a new set nobody would have
|
|
43
43
|
* translated for one sentence.
|
|
44
|
+
*
|
|
45
|
+
* Edited: Claude - Date: 2026-09-16
|
|
46
|
+
* Exported, because the next-30-days section names months too. Two copies
|
|
47
|
+
* of this list is two chances for one of them to be edited - and the whole
|
|
48
|
+
* value of leaning on `data.months.*` is that there is exactly one set of
|
|
49
|
+
* month names on the site, in fifteen languages, that somebody has already
|
|
50
|
+
* proof-read.
|
|
44
51
|
*/
|
|
45
|
-
const MONTHS = [
|
|
52
|
+
export const MONTHS = [
|
|
46
53
|
'january', 'february', 'march', 'april', 'may', 'june',
|
|
47
54
|
'july', 'august', 'september', 'october', 'november', 'december'
|
|
48
55
|
];
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -8,9 +8,20 @@ import { formatDuration, splitPrice } from '../refine';
|
|
|
8
8
|
import { Button, RouteFeature, Information, Rating } from '@autobusal/common';
|
|
9
9
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
10
10
|
import { createDate, getDayParts } from '@autobusal/utilities';
|
|
11
|
-
import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay, Standard, Stops, StopsItems, StopRow, StopTime, StopDay, StopBorder } from './styles';
|
|
11
|
+
import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, SeatsLeft, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay, Standard, Stops, StopsItems, StopRow, StopTime, StopDay, StopBorder } from './styles';
|
|
12
12
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* At or below this many seats, the count is emphasised.
|
|
16
|
+
*
|
|
17
|
+
* Edited: Claude - Date: 2026-09-16
|
|
18
|
+
*
|
|
19
|
+
* A coach carries roughly fifty, so single figures are the point at which
|
|
20
|
+
* the number changes what somebody does about it rather than merely
|
|
21
|
+
* describing the bus.
|
|
22
|
+
*/
|
|
23
|
+
const LOW_SEATS = 5;
|
|
24
|
+
|
|
14
25
|
// neutral placeholder so a missing/broken operator logo doesn't render the
|
|
15
26
|
// browser's broken-image icon in the route results
|
|
16
27
|
const LOGO_FALLBACK = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='60' viewBox='0 0 120 60'%3E%3Crect width='120' height='60' rx='6' fill='%23e5e7eb'/%3E%3Cpath d='M42 22h36v14a2 2 0 0 1-2 2h-2a3 3 0 0 1-6 0H52a3 3 0 0 1-6 0h-2a2 2 0 0 1-2-2z' fill='%239ca3af'/%3E%3C/svg%3E";
|
|
@@ -78,6 +89,28 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
|
|
|
78
89
|
? `${ import.meta.env.VITE_API_OBT }/api/external/go/${ data.external.offer_id }`
|
|
79
90
|
: undefined;
|
|
80
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Seats still on sale on this coach.
|
|
94
|
+
*
|
|
95
|
+
* Edited: Claude - Date: 2026-09-16
|
|
96
|
+
*
|
|
97
|
+
* Typed rather than truthy-checked: 0 is a real answer from obtapi and it
|
|
98
|
+
* means a full bus, which never reaches a result list - and a `!` test
|
|
99
|
+
* would have hidden the field on exactly the coach it mattered most on if
|
|
100
|
+
* one ever did. An external-provider offer (Mode A) carries no count of
|
|
101
|
+
* ours, so it simply shows nothing.
|
|
102
|
+
*
|
|
103
|
+
* Edited: Claude - Date: 2026-09-16
|
|
104
|
+
* `seats_left`, NOT `trip.available_seats`. The first version of this read
|
|
105
|
+
* the capacity column - the only seat number the payload had - and shipped
|
|
106
|
+
* it as "Seats left", which announces an empty coach on a sold-out one.
|
|
107
|
+
* The search has always worked out the real figure to decide whether to
|
|
108
|
+
* offer the departure; it just never sent it. Now it does.
|
|
109
|
+
*/
|
|
110
|
+
const seats = typeof data.seats_left === 'number' && data.seats_left > 0
|
|
111
|
+
? data.seats_left
|
|
112
|
+
: null;
|
|
113
|
+
|
|
81
114
|
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
82
115
|
// Only today's results carry this - obtapi sends null on every other date,
|
|
83
116
|
// so there is no "in 26 days" badge to suppress here.
|
|
@@ -296,6 +329,19 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
|
|
|
296
329
|
{ details ? <HiChevronUp /> : <HiChevronDown /> }
|
|
297
330
|
{ t('routes_order.step2.route.details') }
|
|
298
331
|
</ButtonDetails>
|
|
332
|
+
|
|
333
|
+
{ /* Claude - 2026-09-16 (Ferjolt: "show somewhere on each search
|
|
334
|
+
result the number of available/bookable seats per route").
|
|
335
|
+
|
|
336
|
+
Last in source, and ordered by class rather than by position -
|
|
337
|
+
see Price in ./styles. It sits inside the fare cell because
|
|
338
|
+
how many are left is a fact about this fare on this coach, and
|
|
339
|
+
it is read at the same moment as the price. */ }
|
|
340
|
+
{ seats !== null && (
|
|
341
|
+
<SeatsLeft className="seats-left" $low={ seats <= LOW_SEATS }>
|
|
342
|
+
{ t('routes_order.step2.route.seats_left', { count: seats }) }
|
|
343
|
+
</SeatsLeft>
|
|
344
|
+
) }
|
|
299
345
|
</Price>
|
|
300
346
|
</Trip>
|
|
301
347
|
|
package/Found/Route/styles.ts
CHANGED
|
@@ -510,6 +510,17 @@ export const Price = styled.div`
|
|
|
510
510
|
flex: 0 0 auto;
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
+
/*
|
|
514
|
+
* Claude - 2026-09-16: last line of the fare cell on a phone, on a row of
|
|
515
|
+
* its own under the note and the toggle. By class for the same reason as
|
|
516
|
+
* the two above - a dynamic-price drop renders an extra child and shifts
|
|
517
|
+
* every nth-child index in this block by one.
|
|
518
|
+
*/
|
|
519
|
+
& > .seats-left {
|
|
520
|
+
order: 5;
|
|
521
|
+
flex: 1 1 100%;
|
|
522
|
+
}
|
|
523
|
+
|
|
513
524
|
@media (min-width: 768px) {
|
|
514
525
|
flex: 0 0 165px;
|
|
515
526
|
flex-direction: column;
|
|
@@ -530,7 +541,8 @@ export const Price = styled.div`
|
|
|
530
541
|
* just now inside the price cell rather than under the whole card.
|
|
531
542
|
*/
|
|
532
543
|
& > .price-notice,
|
|
533
|
-
& > .details-toggle
|
|
544
|
+
& > .details-toggle,
|
|
545
|
+
& > .seats-left {
|
|
534
546
|
order: initial;
|
|
535
547
|
flex-basis: auto;
|
|
536
548
|
}
|
|
@@ -970,3 +982,33 @@ export const Day = styled.span`
|
|
|
970
982
|
white-space: nowrap;
|
|
971
983
|
color: ${ props => props.theme.primary.normal };
|
|
972
984
|
`;
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* How many seats are still on sale, under the fare.
|
|
988
|
+
*
|
|
989
|
+
* Edited: Claude - Date: 2026-09-16
|
|
990
|
+
*
|
|
991
|
+
* Ferjolt asked for the seat count on every result. obtapi has always sent
|
|
992
|
+
* it (trip.available_seats) and nothing on the card ever showed it, so the
|
|
993
|
+
* only way to find out how full a coach was, was to try to book it.
|
|
994
|
+
*
|
|
995
|
+
* The string is "Seats left: 49", a label and a number, rather than
|
|
996
|
+
* "49 seats left" - this codebase carries no i18next plural rules, and a
|
|
997
|
+
* counted noun that has to agree in fifteen languages is how "1 stops"
|
|
998
|
+
* happened in the timetable. A colon sidesteps the agreement entirely and
|
|
999
|
+
* translates cleanly into all of them.
|
|
1000
|
+
*
|
|
1001
|
+
* Low stock is emphasised rather than coloured red on its own: it is a real
|
|
1002
|
+
* fact about the bus, not a countdown we invented to hurry anyone.
|
|
1003
|
+
*/
|
|
1004
|
+
export const SeatsLeft = styled.span<{ $low: boolean }>`
|
|
1005
|
+
font-size: ${ props => props.theme.size.xxs };
|
|
1006
|
+
line-height: 1.25;
|
|
1007
|
+
text-align: left;
|
|
1008
|
+
font-weight: ${ props => (props.$low ? 700 : 400) };
|
|
1009
|
+
color: ${ props => (props.$low ? props.theme.font.error : props.theme.font.faded) };
|
|
1010
|
+
|
|
1011
|
+
@media (min-width: 768px) {
|
|
1012
|
+
text-align: center;
|
|
1013
|
+
}
|
|
1014
|
+
`;
|