@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/Facts.tsx
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Container, Grid, Fact, Label, Value, Note, Notice, Operators, Prose } from './styles';
|
|
3
|
+
import { FactsData } from './types';
|
|
4
|
+
import { DAYS, duration } from './questions';
|
|
5
|
+
import { stopped, detail } from './service';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
id: string
|
|
9
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
10
|
+
// Handed in rather than fetched here. The schedule table is built from the
|
|
11
|
+
// same response, and two components each running the same query would have
|
|
12
|
+
// meant the prerender crawl paying for every pair twice.
|
|
13
|
+
facts: FactsData
|
|
14
|
+
t: TFunction<'common'>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The facts about this city pair.
|
|
19
|
+
*
|
|
20
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
21
|
+
*
|
|
22
|
+
* Roadmap Tier 3.2. What somebody searching "Tirana to Durres bus" wants
|
|
23
|
+
* answered before they trust the page: cost, length, when the first and last
|
|
24
|
+
* coach go, how many there are, how far, and who runs it.
|
|
25
|
+
*
|
|
26
|
+
* Everything here is read off the TIMETABLE rather than a dated search, so it
|
|
27
|
+
* does not change with the date in the URL. That is deliberate: a "from EUR
|
|
28
|
+
* 12" that moved depending on which day a crawler arrived would look unstable
|
|
29
|
+
* to a search engine and dishonest to a reader who came back.
|
|
30
|
+
*
|
|
31
|
+
* Renders nothing at all when the API has no facts for the pair. An empty
|
|
32
|
+
* facts block is worse than none - it is exactly the thin content this tier
|
|
33
|
+
* exists to remove.
|
|
34
|
+
*
|
|
35
|
+
* Edited: Claude - Date: 2026-08-20
|
|
36
|
+
*
|
|
37
|
+
* It now also answers "what is this journey LIKE", which is the half a
|
|
38
|
+
* grid of numbers cannot: how often it runs, how many of the coaches are
|
|
39
|
+
* non-stop, how many travel overnight, where they call, what is on board,
|
|
40
|
+
* and whether it crosses a border. Sentences rather than more tiles,
|
|
41
|
+
* because that is the half a reader takes away and a machine can quote.
|
|
42
|
+
*
|
|
43
|
+
* The same restraint applies to all of it: a sentence whose data is
|
|
44
|
+
* missing is not printed, and one whose data supports only a weaker claim
|
|
45
|
+
* is printed in the weaker wording. A pair with one weekly coach ends up
|
|
46
|
+
* with a short honest block, not a padded one.
|
|
47
|
+
*/
|
|
48
|
+
const Facts = ({ id, facts, t }: Props): JSX.Element => {
|
|
49
|
+
/**
|
|
50
|
+
* Edited: Claude - Date: 2026-08-20
|
|
51
|
+
*
|
|
52
|
+
* Whether this coach leaves the country, which is the first thing a
|
|
53
|
+
* traveller wants settled and the one fact here that is not about the
|
|
54
|
+
* timetable at all - it is about the two cities. Silent when either
|
|
55
|
+
* country is unknown rather than assuming the pair is domestic, and
|
|
56
|
+
* silent on the wording of what a border crossing REQUIRES: entry rules
|
|
57
|
+
* are the traveller's own and this page has no business inventing them.
|
|
58
|
+
*/
|
|
59
|
+
const countries = (() => {
|
|
60
|
+
if (!facts.from_country || !facts.to_country) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return facts.from_country === facts.to_country
|
|
65
|
+
? <p>{ t('routes_order.facts.domestic', { country: facts.from_country }) }</p>
|
|
66
|
+
: <p>{ t('routes_order.facts.international', { from_country: facts.from_country, to_country: facts.to_country }) }</p>;
|
|
67
|
+
})();
|
|
68
|
+
|
|
69
|
+
const via = facts.via && facts.via.cities.length > 0 ? facts.via : null;
|
|
70
|
+
|
|
71
|
+
const amenities = facts.amenities && facts.amenities.names.length > 0 ? facts.amenities : null;
|
|
72
|
+
|
|
73
|
+
// Edited: Claude - Date: 2026-08-20
|
|
74
|
+
// A thin pair can have none of the four sentences below, and an empty
|
|
75
|
+
// <Prose> is still a box with a top margin - a gap under the grid that
|
|
76
|
+
// reads as something failing to load.
|
|
77
|
+
const prose = Boolean(countries || via || amenities || facts.operators.length > 0);
|
|
78
|
+
|
|
79
|
+
const range = facts.duration_min !== null && facts.duration_max !== null
|
|
80
|
+
? (facts.duration_min === facts.duration_max
|
|
81
|
+
? duration(facts.duration_min, t)
|
|
82
|
+
: `${ duration(facts.duration_min, t) } – ${ duration(facts.duration_max, t) }`)
|
|
83
|
+
: null;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Edited: Claude - Date: 2026-08-21
|
|
87
|
+
*
|
|
88
|
+
* SAY IT, rather than leaving a reader to work it out. A route is
|
|
89
|
+
* published to the sitemap on a flag nothing re-checks, so a season
|
|
90
|
+
* ending never un-publishes its page - and the page's only tell was a
|
|
91
|
+
* missing frequency tile and a booking wizard that came back empty on
|
|
92
|
+
* every date tried. That is a visitor doing our diagnostics for us and
|
|
93
|
+
* concluding the site is broken.
|
|
94
|
+
*
|
|
95
|
+
* DORMANT NAMES THE DATE. "Not currently scheduled" alone reads as a
|
|
96
|
+
* fault; "the last service ran on 30 September 2024" reads as a season,
|
|
97
|
+
* which is what it usually is, and tells a traveller whether to come back
|
|
98
|
+
* in the summer or give up. A route that never had a timetable has no
|
|
99
|
+
* such date and gets the sentence that says so instead of a borrowed one.
|
|
100
|
+
*
|
|
101
|
+
* AND IT POINTS ONWARD in the same breath. The nearby-pair links below
|
|
102
|
+
* are live journeys on live routes; somebody who came here for a bus is
|
|
103
|
+
* one click from one, and the whole reason this page keeps its ranking
|
|
104
|
+
* rather than being de-listed is that it can make that click happen.
|
|
105
|
+
*/
|
|
106
|
+
const dead = stopped(facts);
|
|
107
|
+
|
|
108
|
+
const said = dead ? detail(dead, t) : null;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Container id={ id } className="box">
|
|
112
|
+
<h2>{ t('routes_order.facts.title', { from: facts.from, to: facts.to }) }</h2>
|
|
113
|
+
|
|
114
|
+
{ dead && (
|
|
115
|
+
<Notice>
|
|
116
|
+
<strong>{ t('routes_order.facts.not_scheduled') }</strong>
|
|
117
|
+
|
|
118
|
+
<p>
|
|
119
|
+
{ said && `${ said } ` }
|
|
120
|
+
{ t('routes_order.facts.not_scheduled_links') }
|
|
121
|
+
</p>
|
|
122
|
+
</Notice>
|
|
123
|
+
) }
|
|
124
|
+
|
|
125
|
+
<Grid>
|
|
126
|
+
{ facts.price_from_display && (
|
|
127
|
+
<Fact>
|
|
128
|
+
<Label>{ t('routes_order.facts.price') }</Label>
|
|
129
|
+
<Value>{ facts.price_from_display }</Value>
|
|
130
|
+
</Fact>
|
|
131
|
+
) }
|
|
132
|
+
|
|
133
|
+
{ range && (
|
|
134
|
+
<Fact>
|
|
135
|
+
<Label>{ t('routes_order.facts.duration') }</Label>
|
|
136
|
+
<Value>{ range }</Value>
|
|
137
|
+
</Fact>
|
|
138
|
+
) }
|
|
139
|
+
|
|
140
|
+
{ facts.departure_first && (
|
|
141
|
+
<Fact>
|
|
142
|
+
<Label>{ t('routes_order.facts.first') }</Label>
|
|
143
|
+
<Value>{ facts.departure_first }</Value>
|
|
144
|
+
</Fact>
|
|
145
|
+
) }
|
|
146
|
+
|
|
147
|
+
{ facts.departure_last && (
|
|
148
|
+
<Fact>
|
|
149
|
+
<Label>{ t('routes_order.facts.last') }</Label>
|
|
150
|
+
<Value>{ facts.departure_last }</Value>
|
|
151
|
+
</Fact>
|
|
152
|
+
) }
|
|
153
|
+
|
|
154
|
+
<Fact>
|
|
155
|
+
<Label>{ t('routes_order.facts.departures') }</Label>
|
|
156
|
+
<Value>{ facts.departures }</Value>
|
|
157
|
+
</Fact>
|
|
158
|
+
|
|
159
|
+
{ /* Edited: Claude - Date: 2026-08-20
|
|
160
|
+
HOW OFTEN, which the departure count above cannot say - ten
|
|
161
|
+
services means something very different once a week than it
|
|
162
|
+
does every day. Absent entirely when no coach on the pair has a
|
|
163
|
+
usable timetable row, including the case that matters: a season
|
|
164
|
+
that has already ended. Saying nothing there is the honest
|
|
165
|
+
answer; "runs 3 times a week" about a coach that stopped
|
|
166
|
+
running last September is not. */ }
|
|
167
|
+
{ facts.frequency && (
|
|
168
|
+
<Fact>
|
|
169
|
+
<Label>{ t('routes_order.facts.frequency') }</Label>
|
|
170
|
+
|
|
171
|
+
<Value>
|
|
172
|
+
{ facts.frequency.daily
|
|
173
|
+
? t('routes_order.facts.frequency_daily')
|
|
174
|
+
: facts.frequency.days.map(day => t(`data.days.${ DAYS[day] }`)).join(', ') }
|
|
175
|
+
|
|
176
|
+
<Note>
|
|
177
|
+
{ facts.frequency.weekly === 1
|
|
178
|
+
? t('routes_order.facts.frequency_weekly_single')
|
|
179
|
+
: t('routes_order.facts.frequency_weekly', { count: facts.frequency.weekly }) }
|
|
180
|
+
</Note>
|
|
181
|
+
</Value>
|
|
182
|
+
</Fact>
|
|
183
|
+
) }
|
|
184
|
+
|
|
185
|
+
{ /* Edited: Claude - Date: 2026-08-20
|
|
186
|
+
Counted out of the total rather than shown as a yes/no. "Direct
|
|
187
|
+
service: yes" on a pair where one coach in ten is direct is
|
|
188
|
+
true and useless; "1 of 10" is what a reader needs to know
|
|
189
|
+
before they start comparing departures. */ }
|
|
190
|
+
{ facts.direct > 0 && (
|
|
191
|
+
<Fact>
|
|
192
|
+
<Label>{ t('routes_order.facts.direct') }</Label>
|
|
193
|
+
<Value>{ t('routes_order.facts.of_total', { count: facts.direct, total: facts.departures }) }</Value>
|
|
194
|
+
</Fact>
|
|
195
|
+
) }
|
|
196
|
+
|
|
197
|
+
{ facts.overnight > 0 && (
|
|
198
|
+
<Fact>
|
|
199
|
+
<Label>{ t('routes_order.facts.overnight') }</Label>
|
|
200
|
+
<Value>{ t('routes_order.facts.of_total', { count: facts.overnight, total: facts.departures }) }</Value>
|
|
201
|
+
</Fact>
|
|
202
|
+
) }
|
|
203
|
+
|
|
204
|
+
{ facts.distance && (
|
|
205
|
+
<Fact>
|
|
206
|
+
<Label>{ t('routes_order.facts.distance') }</Label>
|
|
207
|
+
|
|
208
|
+
<Value>
|
|
209
|
+
{ t('routes_order.facts.distance_value', {
|
|
210
|
+
km: facts.distance.road ?? facts.distance.approx
|
|
211
|
+
}) }
|
|
212
|
+
|
|
213
|
+
{ /* The caveat appears only on the approximation. A real road
|
|
214
|
+
distance needs no apology; a sum of straight lines does,
|
|
215
|
+
because it understates every journey - 309 km against 401
|
|
216
|
+
km of tarmac on Tirana-Thessaloniki. Printing that bare, as
|
|
217
|
+
though it were the driving distance, would be quietly wrong
|
|
218
|
+
on every single pair. */ }
|
|
219
|
+
{ facts.distance.road === null && (
|
|
220
|
+
<Note>{ t('routes_order.facts.distance_note') }</Note>
|
|
221
|
+
) }
|
|
222
|
+
</Value>
|
|
223
|
+
</Fact>
|
|
224
|
+
) }
|
|
225
|
+
</Grid>
|
|
226
|
+
|
|
227
|
+
{ /* Edited: Claude - Date: 2026-08-20
|
|
228
|
+
The part somebody reads rather than scans. Four sentences at most,
|
|
229
|
+
each one printed only when the data behind it exists, and none of
|
|
230
|
+
them a generality - a pair served by one weekly coach gets one
|
|
231
|
+
sentence and it is true, which is a better page than four
|
|
232
|
+
sentences of hedging.
|
|
233
|
+
|
|
234
|
+
EVERY/SOME IS THE WHOLE CARE HERE. Buses on one route are not
|
|
235
|
+
interchangeable: six of the ten Tirana-Thessaloniki coaches run
|
|
236
|
+
non-stop and they carry different equipment. "Every bus calls at
|
|
237
|
+
Korca" and "some buses also call at Korca" are different claims
|
|
238
|
+
and obtapi says which one the data supports, so the copy never
|
|
239
|
+
has to guess. */ }
|
|
240
|
+
{ prose && (
|
|
241
|
+
<Prose>
|
|
242
|
+
{ countries }
|
|
243
|
+
|
|
244
|
+
{ via && (
|
|
245
|
+
<p>
|
|
246
|
+
{ t(via.every ? 'routes_order.facts.via_every' : 'routes_order.facts.via_some', {
|
|
247
|
+
cities: via.cities.join(', ')
|
|
248
|
+
}) }
|
|
249
|
+
</p>
|
|
250
|
+
) }
|
|
251
|
+
|
|
252
|
+
{ amenities && (
|
|
253
|
+
<p>
|
|
254
|
+
{ t(amenities.every ? 'routes_order.facts.amenities_every' : 'routes_order.facts.amenities_some', {
|
|
255
|
+
amenities: amenities.names.join(', ')
|
|
256
|
+
}) }
|
|
257
|
+
</p>
|
|
258
|
+
) }
|
|
259
|
+
|
|
260
|
+
{ facts.operators.length > 0 && (
|
|
261
|
+
<Operators>
|
|
262
|
+
{ t('routes_order.facts.operators', { operators: facts.operators.join(', ') }) }
|
|
263
|
+
</Operators>
|
|
264
|
+
) }
|
|
265
|
+
</Prose>
|
|
266
|
+
) }
|
|
267
|
+
</Container>
|
|
268
|
+
);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export default Facts;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { FactsData } from './types';
|
|
3
|
+
import { stopped, sentence } from './service';
|
|
4
|
+
|
|
5
|
+
export interface Question {
|
|
6
|
+
// stable enough to key a list on, and never rendered
|
|
7
|
+
id: string
|
|
8
|
+
q: string
|
|
9
|
+
a: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ISO weekday to the short day names the rest of the site already ships in
|
|
14
|
+
* all fifteen languages (`data.days.*`) - "Mon, Wed, Fri" rather than a new
|
|
15
|
+
* set of long names nobody would have translated for a week.
|
|
16
|
+
*/
|
|
17
|
+
export const DAYS: Record<number, string> = {
|
|
18
|
+
1: 'mon', 2: 'tue', 3: 'wed', 4: 'thu', 5: 'fri', 6: 'sat', 7: 'sun'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The questions a pair page answers, and their answers.
|
|
23
|
+
*
|
|
24
|
+
* Edited: Claude - Date: 2026-08-20
|
|
25
|
+
*
|
|
26
|
+
* ONE BUILDER, TWO CONSUMERS, AND THAT IS THE ENTIRE POINT. The FAQPage
|
|
27
|
+
* structured data and the FAQ a human reads are built from this same array,
|
|
28
|
+
* so the schema's `acceptedAnswer` is the identical string the page renders
|
|
29
|
+
* a few hundred pixels away - character for character, in whatever language
|
|
30
|
+
* the reader is in. Google requires that match and pulls a site's rich
|
|
31
|
+
* results when it fails; two lists of hand-written questions kept in step by
|
|
32
|
+
* good intentions is exactly how it fails, six months later, in one locale
|
|
33
|
+
* nobody re-read.
|
|
34
|
+
*
|
|
35
|
+
* EVERY ANSWER IS A FACT obtapi COMPUTED, never an estimate and never a
|
|
36
|
+
* generality. A question whose data is missing is not asked: no "typically
|
|
37
|
+
* around 6 hours", no "usually several a day". A pair served by one coach a
|
|
38
|
+
* week gets four questions and they are all true, which is a better page
|
|
39
|
+
* than eight questions padded with hedges.
|
|
40
|
+
*
|
|
41
|
+
* Ordered by how often the question is actually typed - how long, how much,
|
|
42
|
+
* how often - rather than by how the payload happens to be shaped.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* A journey length, in words.
|
|
46
|
+
*
|
|
47
|
+
* Edited: Claude - Date: 2026-08-20
|
|
48
|
+
*
|
|
49
|
+
* "07:30" built from a duration in minutes reads as a time of day, so the
|
|
50
|
+
* hours and minutes are spelled out - and a whole number of hours drops the
|
|
51
|
+
* "0m" rather than printing it.
|
|
52
|
+
*
|
|
53
|
+
* Exported because the facts block prints the same number a few hundred
|
|
54
|
+
* pixels from the FAQ answer that quotes it, and it had its own copy of
|
|
55
|
+
* this without the whole-hour rule: a three-hour coach read "3h 0m" in the
|
|
56
|
+
* grid and "3h" in the answer directly below it, on the same page, about
|
|
57
|
+
* the same bus.
|
|
58
|
+
*/
|
|
59
|
+
export const duration = (minutes: number, t: TFunction<'common'>): string => (
|
|
60
|
+
minutes % 60 === 0
|
|
61
|
+
? t('routes_order.facts.duration_hours', { hours: minutes / 60 })
|
|
62
|
+
: t('routes_order.facts.duration_value', { hours: Math.floor(minutes / 60), minutes: minutes % 60 })
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
|
|
66
|
+
const all: Question[] = [];
|
|
67
|
+
|
|
68
|
+
const cities = { from: facts.from, to: facts.to };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Edited: Claude - Date: 2026-08-21
|
|
72
|
+
*
|
|
73
|
+
* THE FIRST QUESTION, WHEN THE ANSWER IS NO. "Is there a bus from Tirana
|
|
74
|
+
* to Prizren?" is the question a pair page exists to answer, and on a
|
|
75
|
+
* pair whose every timetable window has closed the honest answer is not
|
|
76
|
+
* currently, with the day it last ran.
|
|
77
|
+
*
|
|
78
|
+
* It also dictates which of the questions BELOW may be asked, which is
|
|
79
|
+
* the part that matters more than the extra entry. Every answer here is
|
|
80
|
+
* a standalone sentence that Google may lift into a rich result with no
|
|
81
|
+
* page around it, so "There are 4 scheduled services from Tirana to
|
|
82
|
+
* Prizren" quoted on its own about a route that stopped in 2025 is a
|
|
83
|
+
* claim we published, not a nuance a reader will supply. The four
|
|
84
|
+
* present-tense SERVICE COUNTS - how many run, what time they leave,
|
|
85
|
+
* how many are direct, how many are overnight - are dropped there.
|
|
86
|
+
* (Frequency drops itself: obtapi already sends null for it.)
|
|
87
|
+
*
|
|
88
|
+
* What stays is what remains true of the journey as published: how long
|
|
89
|
+
* it takes, what it cost, where it calls, what is on board, who runs it,
|
|
90
|
+
* how far it is. Those describe the route rather than assert this
|
|
91
|
+
* morning's departures, and each of them sits under an answer that has
|
|
92
|
+
* already said the route is not running.
|
|
93
|
+
*/
|
|
94
|
+
const dead = stopped(facts);
|
|
95
|
+
|
|
96
|
+
if (dead) {
|
|
97
|
+
all.push({
|
|
98
|
+
id: 'service',
|
|
99
|
+
q: t('routes_order.schema.service_q', cities),
|
|
100
|
+
a: sentence(dead, t)
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (facts.duration_min !== null && facts.duration_max !== null) {
|
|
105
|
+
all.push({
|
|
106
|
+
id: 'duration',
|
|
107
|
+
q: t('routes_order.schema.duration_q', cities),
|
|
108
|
+
a: facts.duration_min === facts.duration_max
|
|
109
|
+
? t('routes_order.schema.duration_a', { ...cities, duration: duration(facts.duration_min, t) })
|
|
110
|
+
: t('routes_order.schema.duration_a_range', { ...cities, min: duration(facts.duration_min, t), max: duration(facts.duration_max, t) })
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (facts.price_from_display) {
|
|
115
|
+
all.push({
|
|
116
|
+
id: 'price',
|
|
117
|
+
q: t('routes_order.schema.price_q', cities),
|
|
118
|
+
|
|
119
|
+
// A range only where there is one. On a pair whose services all cost
|
|
120
|
+
// the same, "from 15.00 EUR to 15.00 EUR" is a sentence that makes a
|
|
121
|
+
// reader distrust the rest of the page.
|
|
122
|
+
a: facts.price_to_display && facts.price_to !== facts.price_from
|
|
123
|
+
? t('routes_order.schema.price_a_range', { ...cities, low: facts.price_from_display, high: facts.price_to_display })
|
|
124
|
+
: t('routes_order.schema.price_a', { ...cities, price: facts.price_from_display })
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (facts.frequency) {
|
|
129
|
+
const days = facts.frequency.days.map(day => t(`data.days.${ DAYS[day] }`)).join(', ');
|
|
130
|
+
|
|
131
|
+
all.push({
|
|
132
|
+
id: 'frequency',
|
|
133
|
+
q: t('routes_order.schema.frequency_q', cities),
|
|
134
|
+
a: facts.frequency.daily
|
|
135
|
+
? t('routes_order.schema.frequency_a_daily', { ...cities, count: facts.frequency.weekly })
|
|
136
|
+
: (facts.frequency.weekly === 1
|
|
137
|
+
? t('routes_order.schema.frequency_a_single', { ...cities, days })
|
|
138
|
+
: t('routes_order.schema.frequency_a', { ...cities, days, count: facts.frequency.weekly }))
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (facts.departures > 0 && !dead) {
|
|
143
|
+
all.push({
|
|
144
|
+
id: 'departures',
|
|
145
|
+
q: t('routes_order.schema.departures_q', cities),
|
|
146
|
+
|
|
147
|
+
// SERVICES, not departures. `facts.departures` counts the distinct
|
|
148
|
+
// coaches in the timetable, and a single daily one of them makes
|
|
149
|
+
// seven departures a week - so beside the frequency answer above,
|
|
150
|
+
// the old wording ("10 scheduled departures" next to "70 a week")
|
|
151
|
+
// read as a contradiction rather than as two facts. Both strings
|
|
152
|
+
// were reworded; the number they print never changed.
|
|
153
|
+
//
|
|
154
|
+
// And "There are 1 scheduled departures" was the sentence a thin
|
|
155
|
+
// pair got for as long as this block has existed - a thin pair being
|
|
156
|
+
// most of them. A separate key rather than an i18next plural: the
|
|
157
|
+
// plural categories of the eight Slavic locales here are not
|
|
158
|
+
// something a single English-shaped rule gets right.
|
|
159
|
+
a: facts.departures === 1
|
|
160
|
+
? t('routes_order.schema.departures_a_single', cities)
|
|
161
|
+
: t('routes_order.schema.departures_a', { ...cities, count: facts.departures })
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (facts.departure_first && facts.departure_last && !dead) {
|
|
166
|
+
all.push({
|
|
167
|
+
id: 'times',
|
|
168
|
+
q: t('routes_order.schema.times_q', cities),
|
|
169
|
+
a: t('routes_order.schema.times_a', { first: facts.departure_first, last: facts.departure_last })
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Asked only where the answer is a real count. `direct` is meaningless
|
|
174
|
+
// without a total to read it against, and `departures` is that total.
|
|
175
|
+
if (facts.departures > 0 && !dead) {
|
|
176
|
+
all.push({
|
|
177
|
+
id: 'direct',
|
|
178
|
+
q: t('routes_order.schema.direct_q', cities),
|
|
179
|
+
a: facts.direct === 0
|
|
180
|
+
? t('routes_order.schema.direct_a_none', cities)
|
|
181
|
+
: (facts.direct === facts.departures
|
|
182
|
+
? t('routes_order.schema.direct_a_all', cities)
|
|
183
|
+
: t('routes_order.schema.direct_a', { ...cities, count: facts.direct, total: facts.departures }))
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Only when there IS a night service. "No, none of these run overnight" is
|
|
188
|
+
// a question asked to be answered no, which is padding.
|
|
189
|
+
if (facts.overnight > 0 && !dead) {
|
|
190
|
+
all.push({
|
|
191
|
+
id: 'overnight',
|
|
192
|
+
q: t('routes_order.schema.overnight_q', cities),
|
|
193
|
+
a: facts.overnight === facts.departures
|
|
194
|
+
? t('routes_order.schema.overnight_a_all', cities)
|
|
195
|
+
: t('routes_order.schema.overnight_a', { ...cities, count: facts.overnight, total: facts.departures })
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (facts.via && facts.via.cities.length > 0) {
|
|
200
|
+
all.push({
|
|
201
|
+
id: 'via',
|
|
202
|
+
q: t('routes_order.schema.via_q', cities),
|
|
203
|
+
|
|
204
|
+
// The same sentence the facts block shows, deliberately - it is the
|
|
205
|
+
// one wording that distinguishes "every coach calls here" from "one of
|
|
206
|
+
// them does", and the answer must not be a looser paraphrase of it.
|
|
207
|
+
a: t(facts.via.every ? 'routes_order.facts.via_every' : 'routes_order.facts.via_some', {
|
|
208
|
+
cities: facts.via.cities.join(', ')
|
|
209
|
+
})
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (facts.amenities && facts.amenities.names.length > 0) {
|
|
214
|
+
all.push({
|
|
215
|
+
id: 'amenities',
|
|
216
|
+
q: t('routes_order.schema.amenities_q', cities),
|
|
217
|
+
a: t(facts.amenities.every ? 'routes_order.facts.amenities_every' : 'routes_order.facts.amenities_some', {
|
|
218
|
+
amenities: facts.amenities.names.join(', ')
|
|
219
|
+
})
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (facts.operators.length > 0) {
|
|
224
|
+
all.push({
|
|
225
|
+
id: 'operators',
|
|
226
|
+
q: t('routes_order.schema.operators_q', cities),
|
|
227
|
+
a: t('routes_order.facts.operators', { operators: facts.operators.join(', ') })
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (facts.distance) {
|
|
232
|
+
all.push({
|
|
233
|
+
id: 'distance',
|
|
234
|
+
q: t('routes_order.schema.distance_q', cities),
|
|
235
|
+
|
|
236
|
+
// The approximation gets its own sentence rather than the same one
|
|
237
|
+
// with a hedge bolted on. It is a sum of straight lines and it is
|
|
238
|
+
// ALWAYS short - 309 km against 412 km of tarmac on
|
|
239
|
+
// Tirana-Thessaloniki - so an answer that called it the road distance
|
|
240
|
+
// "approximately" would be wrong by a quarter on every pair.
|
|
241
|
+
a: facts.distance.road !== null
|
|
242
|
+
? t('routes_order.schema.distance_a', { ...cities, km: facts.distance.road })
|
|
243
|
+
: t('routes_order.schema.distance_a_approx', { ...cities, km: facts.distance.approx })
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return all;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export default questions;
|
package/Facts/service.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { FactsData } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A pair that is published but has nothing left to sell.
|
|
6
|
+
*
|
|
7
|
+
* Edited: Claude - Date: 2026-08-21
|
|
8
|
+
*
|
|
9
|
+
* ONE READING OF THE FLAG, shared by everything that reacts to it - the
|
|
10
|
+
* notice in the facts block, the FAQ answer that a search engine quotes,
|
|
11
|
+
* and the schema.org availability. Three separate `facts.service?.scheduled
|
|
12
|
+
* === false` checks would be three chances for the page to say one thing
|
|
13
|
+
* and its own structured data to say another, which is the exact mismatch
|
|
14
|
+
* that gets a site's rich results pulled.
|
|
15
|
+
*
|
|
16
|
+
* `dormant` is a pair that RAN and stopped, and `last` is the day it last
|
|
17
|
+
* ran. `never` is a route published before anybody gave it a timetable -
|
|
18
|
+
* there is no last day to name, and the copy must not imply there was one.
|
|
19
|
+
*
|
|
20
|
+
* NULL for a live pair AND for an old cached payload with no `service` key
|
|
21
|
+
* at all (obtapi caches these for six hours, so the field is missing from
|
|
22
|
+
* warm entries for six hours after a deploy). Both mean "say nothing",
|
|
23
|
+
* which is right: an absent field is not evidence that a route is dead.
|
|
24
|
+
*/
|
|
25
|
+
export interface Stopped {
|
|
26
|
+
state: 'dormant' | 'never'
|
|
27
|
+
last: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const stopped = (facts: FactsData): (Stopped | null) => {
|
|
31
|
+
if (!facts.service || facts.service.scheduled !== false) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return facts.service.last
|
|
36
|
+
? { state: 'dormant', last: facts.service.last }
|
|
37
|
+
: { state: 'never', last: null };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ISO month index to the month names the site already ships in all fifteen
|
|
42
|
+
* languages (`data.months.*`), rather than a new set nobody would have
|
|
43
|
+
* translated for one sentence.
|
|
44
|
+
*/
|
|
45
|
+
const MONTHS = [
|
|
46
|
+
'january', 'february', 'march', 'april', 'may', 'june',
|
|
47
|
+
'july', 'august', 'september', 'october', 'november', 'december'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* "The last service ran on 30 September 2024", in the reader's language.
|
|
52
|
+
*
|
|
53
|
+
* Edited: Claude - Date: 2026-08-21
|
|
54
|
+
*
|
|
55
|
+
* The day, month and year go in as THREE interpolations rather than one
|
|
56
|
+
* pre-joined string, so a locale that orders them differently can. obtapi
|
|
57
|
+
* sends the date as ISO precisely because it is the one format that does
|
|
58
|
+
* not already contain somebody's opinion about that ordering.
|
|
59
|
+
*
|
|
60
|
+
* Returns null on anything that is not an ISO date rather than printing a
|
|
61
|
+
* half-parsed one. A malformed date here would be printed as fact in a
|
|
62
|
+
* sentence about when a bus last ran, and no date at all is better than a
|
|
63
|
+
* wrong one - the headline above it already carries the actual message.
|
|
64
|
+
*/
|
|
65
|
+
export const ran = (iso: string, t: TFunction<'common'>): (string | null) => {
|
|
66
|
+
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
67
|
+
|
|
68
|
+
if (!parts) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const month = MONTHS[Number(parts[2]) - 1];
|
|
73
|
+
|
|
74
|
+
if (!month) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return t('routes_order.facts.not_scheduled_last', {
|
|
79
|
+
day: String(Number(parts[3])),
|
|
80
|
+
month: t(`data.months.${ month }`),
|
|
81
|
+
year: parts[1]
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The second sentence: when it last ran, or that it never has.
|
|
87
|
+
*
|
|
88
|
+
* Null rather than the never-scheduled wording when a DORMANT pair's date
|
|
89
|
+
* fails to parse - "a timetable has not been published yet" about a coach
|
|
90
|
+
* that ran for two years is a different false statement, not a graceful
|
|
91
|
+
* fallback. The headline carries the message on its own there.
|
|
92
|
+
*/
|
|
93
|
+
export const detail = (item: Stopped, t: TFunction<'common'>): (string | null) => (
|
|
94
|
+
item.last ? ran(item.last, t) : (item.state === 'never' ? t('routes_order.facts.not_scheduled_never') : null)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The whole message as one paragraph of running text.
|
|
99
|
+
*
|
|
100
|
+
* Shared with the FAQ answer for the same reason the flag is: Google
|
|
101
|
+
* requires the `acceptedAnswer` in the markup to be the text on the page,
|
|
102
|
+
* and the only way to guarantee that across fifteen languages is for there
|
|
103
|
+
* to be one string rather than two that agree today.
|
|
104
|
+
*/
|
|
105
|
+
export const sentence = (item: Stopped, t: TFunction<'common'>): string => {
|
|
106
|
+
const second = detail(item, t);
|
|
107
|
+
|
|
108
|
+
return second
|
|
109
|
+
? `${ t('routes_order.facts.not_scheduled') } ${ second }`
|
|
110
|
+
: t('routes_order.facts.not_scheduled');
|
|
111
|
+
};
|
|
@@ -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
|
+
);
|