@autobusal/routes-order 1.32.1 → 1.33.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 +22 -0
- package/Facts/Facts.tsx +167 -15
- package/Facts/questions.ts +250 -0
- package/Facts/service.ts +111 -0
- package/Facts/styles.ts +64 -0
- package/Facts/types.ts +91 -1
- package/Faq/Faq.tsx +52 -0
- package/Faq/styles.ts +68 -0
- package/Links/Links.tsx +27 -3
- package/Links/styles.ts +30 -0
- package/Sections/Schema.tsx +61 -49
- package/Sections/Sections.tsx +68 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.33.1
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **A pair with no future scheduled service says so**, with the date it last ran, instead of showing a facts block that quietly omits frequency and a departure list that is empty on every date the visitor tries. The links to journeys that do run stay prominent, so the page converts rather than dead-ends. No `noindex`: an honest page about a seasonal route is not thin content, and a route that stopped in December will want its ranking back in June.
|
|
8
|
+
|
|
9
|
+
## 1.33.0
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **The pair page is a page about the journey, not just a search box over it.** `/routes/search/facts` now carries how often the pair is served (the union of days a traveller can leave on, plus departures per week), how many of the scheduled services run non-stop, how many travel overnight, the cities in between, what is on board, the two countries, and the dearest fare alongside the cheapest - all read off the timetable, all riding the existing 6h cache, none of it estimated.
|
|
14
|
+
- Every list-shaped fact carries an `every` flag beside it, and the copy is chosen from that flag. Six of the ten Tirana-Thessaloniki coaches are non-stop and they carry different equipment, so "every bus calls at Korca" and "some buses also call at Korca" are different claims; obtapi says which one the data supports rather than leaving the wording to guess.
|
|
15
|
+
- **A visible FAQ** (`Faq/`), built from `Facts/questions` - the same array the `FAQPage` structured data is emitted from, so the answer Google quotes is the string the page renders, character for character, in all fifteen languages. Native `<details>`, no state: the answer is in the DOM whether it is open or shut, which is what a prerendered snapshot and a crawler that does not click both need. Questions with no data behind them are not asked.
|
|
16
|
+
- **`BusTrip` gained an `itinerary`** - but only where every service on the pair calls at every stop listed. An itinerary is a statement about *the* trip, and on a pair where most coaches run non-stop there is no single itinerary to state. Departure and arrival stops carry a `PostalAddress` where obtapi knows the country.
|
|
17
|
+
- **A "travelling back?" link**, from the new `links.reverse`. Pairs are directional, so the return leg is a different page with its own timetable - and it belonged to neither "popular from" nor "popular to", being a pair from the destination back to the origin. Null, and absent, when nobody sells it.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **"3h 0m" in the facts grid, "3h" in the answer below it.** The facts block carried its own copy of the duration formatter without the whole-hour rule the FAQ answers use, so a three-hour coach was described two ways on one page. One exported formatter now.
|
|
22
|
+
- **The departure count called itself the wrong thing.** `facts.departures` counts the distinct coaches in the timetable, and a single daily one of them makes seven departures a week - so "10 scheduled departures" beside the new "every day, 70 scheduled departures a week" read as a contradiction rather than as two facts. The label and the FAQ answer now say *scheduled services* in all fifteen languages; the number they print is unchanged.
|
|
23
|
+
- **"There are 1 scheduled departures"** - the sentence a thin pair got from the FAQ schema for as long as the block has existed, and a thin pair is most of them. A separate key rather than an i18next plural rule; the plural categories of the eight Slavic locales here are not something one English-shaped rule gets right.
|
|
24
|
+
|
|
3
25
|
## 1.32.1
|
|
4
26
|
|
|
5
27
|
### Added
|
package/Facts/Facts.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
|
-
import { Container, Grid, Fact, Label, Value, Note, Operators } from './styles';
|
|
2
|
+
import { Container, Grid, Fact, Label, Value, Note, Notice, Operators, Prose } from './styles';
|
|
3
3
|
import { FactsData } from './types';
|
|
4
|
+
import { DAYS, duration } from './questions';
|
|
5
|
+
import { stopped, detail } from './service';
|
|
4
6
|
|
|
5
7
|
interface Props {
|
|
6
8
|
id: string
|
|
@@ -29,27 +31,97 @@ interface Props {
|
|
|
29
31
|
* Renders nothing at all when the API has no facts for the pair. An empty
|
|
30
32
|
* facts block is worse than none - it is exactly the thin content this tier
|
|
31
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.
|
|
32
47
|
*/
|
|
33
48
|
const Facts = ({ id, facts, t }: Props): JSX.Element => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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);
|
|
42
78
|
|
|
43
79
|
const range = facts.duration_min !== null && facts.duration_max !== null
|
|
44
80
|
? (facts.duration_min === facts.duration_max
|
|
45
|
-
? duration(facts.duration_min)
|
|
46
|
-
: `${ duration(facts.duration_min) } – ${ duration(facts.duration_max) }`)
|
|
81
|
+
? duration(facts.duration_min, t)
|
|
82
|
+
: `${ duration(facts.duration_min, t) } – ${ duration(facts.duration_max, t) }`)
|
|
47
83
|
: null;
|
|
48
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
|
+
|
|
49
110
|
return (
|
|
50
111
|
<Container id={ id } className="box">
|
|
51
112
|
<h2>{ t('routes_order.facts.title', { from: facts.from, to: facts.to }) }</h2>
|
|
52
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
|
+
|
|
53
125
|
<Grid>
|
|
54
126
|
{ facts.price_from_display && (
|
|
55
127
|
<Fact>
|
|
@@ -84,6 +156,51 @@ const Facts = ({ id, facts, t }: Props): JSX.Element => {
|
|
|
84
156
|
<Value>{ facts.departures }</Value>
|
|
85
157
|
</Fact>
|
|
86
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
|
+
|
|
87
204
|
{ facts.distance && (
|
|
88
205
|
<Fact>
|
|
89
206
|
<Label>{ t('routes_order.facts.distance') }</Label>
|
|
@@ -107,10 +224,45 @@ const Facts = ({ id, facts, t }: Props): JSX.Element => {
|
|
|
107
224
|
) }
|
|
108
225
|
</Grid>
|
|
109
226
|
|
|
110
|
-
{
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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>
|
|
114
266
|
) }
|
|
115
267
|
</Container>
|
|
116
268
|
);
|
|
@@ -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
|
+
};
|
package/Facts/styles.ts
CHANGED
|
@@ -46,3 +46,67 @@ export const Operators = styled.p`
|
|
|
46
46
|
font-size: ${ props => props.theme.size.s };
|
|
47
47
|
line-height: 1.5;
|
|
48
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
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
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
|
+
|
|
1
14
|
export interface FactsData {
|
|
2
15
|
from: string
|
|
3
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
|
|
4
23
|
// cheapest adult fare anybody sells this leg for
|
|
5
24
|
price_from: number | null
|
|
6
25
|
// formatted by the API with the brand's own currency - the PUBLIC settings
|
|
7
26
|
// payload carries no currency, so a frontend has nothing to format with
|
|
8
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
|
|
9
35
|
// ISO 4217, for schema.org's Offer - the display string has the symbol
|
|
10
36
|
// baked in and guessing a code back out of it would be confidently wrong
|
|
11
37
|
currency: string | null
|
|
@@ -17,6 +43,64 @@ export interface FactsData {
|
|
|
17
43
|
// how many coaches serve the pair in the TIMETABLE, not on a given date
|
|
18
44
|
departures: number
|
|
19
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
|
|
20
104
|
// Edited: Claude - Date: 2026-08-20
|
|
21
105
|
// The pair's own review score - the published reviews of every route in
|
|
22
106
|
// this very timetable, aggregated by obtapi with the same threshold rule
|
|
@@ -73,5 +157,11 @@ export interface FactsResponse {
|
|
|
73
157
|
facts: FactsData | null
|
|
74
158
|
// Tier 3.4 - pairs leaving the origin, and pairs arriving at the
|
|
75
159
|
// destination. Present even when `facts` is null.
|
|
76
|
-
|
|
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 }
|
|
77
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/Links/Links.tsx
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
|
-
import { Container, Group, Heading, List, Item } from './styles';
|
|
2
|
+
import { Container, Group, Heading, List, Item, Reverse, ReverseLink } from './styles';
|
|
3
3
|
import { LinkRow } from '../Facts/types';
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
id: string
|
|
7
7
|
from: LinkRow[]
|
|
8
8
|
to: LinkRow[]
|
|
9
|
+
// Edited: Claude - Date: 2026-08-20
|
|
10
|
+
// The journey home, or null when it is not sold. Belongs to neither list
|
|
11
|
+
// below - it is a pair from the DESTINATION back to the origin - which is
|
|
12
|
+
// why the one link a reader is most likely to want next was the one this
|
|
13
|
+
// block could not produce.
|
|
14
|
+
reverse: LinkRow | null
|
|
9
15
|
fromName?: string
|
|
10
16
|
toName?: string
|
|
11
17
|
t: TFunction<'common'>
|
|
@@ -26,13 +32,31 @@ interface Props {
|
|
|
26
32
|
* Ordered by paid bookings server-side, but never filtered by them - the
|
|
27
33
|
* pairs with no orders yet are exactly the orphans this exists to reach.
|
|
28
34
|
*/
|
|
29
|
-
const Links = ({ id, from, to, fromName, toName, t }: Props): (JSX.Element | null) => {
|
|
30
|
-
if (from.length === 0 && to.length === 0) {
|
|
35
|
+
const Links = ({ id, from, to, reverse, fromName, toName, t }: Props): (JSX.Element | null) => {
|
|
36
|
+
if (from.length === 0 && to.length === 0 && !reverse) {
|
|
31
37
|
return null;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
return (
|
|
35
41
|
<Container id={ id } className="box">
|
|
42
|
+
{ /* Edited: Claude - Date: 2026-08-20
|
|
43
|
+
First, and on its own, because pairs are DIRECTIONAL: the return
|
|
44
|
+
leg is a different page with its own timetable and its own fares,
|
|
45
|
+
and it is the likeliest next page for anybody who is planning a
|
|
46
|
+
trip rather than a one-way transfer. Only ever rendered when
|
|
47
|
+
obtapi found the reverse pair among the ones it actually sells -
|
|
48
|
+
the same list the sitemap publishes - so it can never point at an
|
|
49
|
+
empty page. */ }
|
|
50
|
+
{ reverse && (
|
|
51
|
+
<Reverse>
|
|
52
|
+
<Heading>{ t('routes_order.links.reverse') }</Heading>
|
|
53
|
+
|
|
54
|
+
<ReverseLink to={ reverse.url }>
|
|
55
|
+
{ t('routes_order.links.reverse_link', { from: reverse.from_name, to: reverse.to_name }) }
|
|
56
|
+
</ReverseLink>
|
|
57
|
+
</Reverse>
|
|
58
|
+
) }
|
|
59
|
+
|
|
36
60
|
{ from.length > 0 && (
|
|
37
61
|
<Group>
|
|
38
62
|
<Heading>{ t('routes_order.links.from', { city: fromName ?? from[0].from_name }) }</Heading>
|
package/Links/styles.ts
CHANGED
|
@@ -43,3 +43,33 @@ export const Item = styled(Link)`
|
|
|
43
43
|
background: ${ props => props.theme.primary.neutral };
|
|
44
44
|
}
|
|
45
45
|
`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The return journey.
|
|
49
|
+
*
|
|
50
|
+
* Edited: Claude - Date: 2026-08-20
|
|
51
|
+
*
|
|
52
|
+
* Set apart from the two "popular routes" lists rather than dropped into
|
|
53
|
+
* one of them: it is a single specific page, not a browse list, and a
|
|
54
|
+
* reader planning a round trip is looking for exactly it.
|
|
55
|
+
*/
|
|
56
|
+
export const Reverse = styled.div`
|
|
57
|
+
margin-bottom: 20px;
|
|
58
|
+
padding-bottom: 20px;
|
|
59
|
+
border-bottom: 1px solid ${ props => props.theme.background.neutral };
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
export const ReverseLink = styled(Link)`
|
|
63
|
+
display: inline-block;
|
|
64
|
+
padding: 8px 16px;
|
|
65
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
66
|
+
background: ${ props => props.theme.primary.neutral };
|
|
67
|
+
color: ${ props => props.theme.font.normal };
|
|
68
|
+
font-size: ${ props => props.theme.size.s };
|
|
69
|
+
font-weight: 700;
|
|
70
|
+
|
|
71
|
+
&:hover {
|
|
72
|
+
text-decoration: none;
|
|
73
|
+
background: ${ props => props.theme.background.neutral };
|
|
74
|
+
}
|
|
75
|
+
`;
|
package/Sections/Schema.tsx
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import { JsonLd } from '@autobusal/common';
|
|
3
3
|
import { FactsData } from '../Facts/types';
|
|
4
|
+
import { Question } from '../Facts/questions';
|
|
5
|
+
import { stopped } from '../Facts/service';
|
|
4
6
|
|
|
5
7
|
interface Props {
|
|
6
8
|
facts: FactsData
|
|
9
|
+
// Edited: Claude - Date: 2026-08-20
|
|
10
|
+
// Built by Facts/questions and handed in, because the FAQ section a few
|
|
11
|
+
// hundred pixels down the page renders this same array. Google requires
|
|
12
|
+
// the answer in the markup to be the answer on the page, and the only way
|
|
13
|
+
// to guarantee that in fifteen languages is for there to be one string,
|
|
14
|
+
// not two that match.
|
|
15
|
+
questions: Question[]
|
|
7
16
|
t: TFunction<'common'>
|
|
8
17
|
}
|
|
9
18
|
|
|
@@ -41,7 +50,7 @@ interface Props {
|
|
|
41
50
|
* emitted here - an AggregateRating of nothing is a guideline violation,
|
|
42
51
|
* worse than none.
|
|
43
52
|
*/
|
|
44
|
-
const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
|
|
53
|
+
const Schema = ({ facts, questions, t }: Props): (JSX.Element | null) => {
|
|
45
54
|
const schedule = facts.schedule ?? [];
|
|
46
55
|
|
|
47
56
|
// Deduplicated operators, each keeping whatever rating it actually has.
|
|
@@ -67,13 +76,49 @@ const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
|
|
|
67
76
|
} : {})
|
|
68
77
|
});
|
|
69
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Edited: Claude - Date: 2026-08-20
|
|
81
|
+
*
|
|
82
|
+
* A BusStop with its town and country on it, where obtapi knows the
|
|
83
|
+
* country. Two cities called Veria in two countries are one string to a
|
|
84
|
+
* machine and two different places to a traveller, and the pair page now
|
|
85
|
+
* says which country each end is in - so the markup can too without
|
|
86
|
+
* claiming anything the page does not.
|
|
87
|
+
*/
|
|
88
|
+
const stop = (name: string, country: string | null) => ({
|
|
89
|
+
'@type': 'BusStop',
|
|
90
|
+
name,
|
|
91
|
+
...(country ? {
|
|
92
|
+
address: {
|
|
93
|
+
'@type': 'PostalAddress',
|
|
94
|
+
addressLocality: name,
|
|
95
|
+
addressCountry: country
|
|
96
|
+
}
|
|
97
|
+
} : {})
|
|
98
|
+
});
|
|
99
|
+
|
|
70
100
|
const trip = {
|
|
71
101
|
'@context': 'https://schema.org',
|
|
72
102
|
'@type': 'BusTrip',
|
|
73
103
|
name: t('routes_order.facts.title', { from: facts.from, to: facts.to }),
|
|
74
104
|
|
|
75
|
-
departureBusStop:
|
|
76
|
-
arrivalBusStop:
|
|
105
|
+
departureBusStop: stop(facts.from, facts.from_country),
|
|
106
|
+
arrivalBusStop: stop(facts.to, facts.to_country),
|
|
107
|
+
|
|
108
|
+
// Edited: Claude - Date: 2026-08-20
|
|
109
|
+
// The stops in between - but ONLY when every service on the pair calls
|
|
110
|
+
// at all of them. An itinerary is a statement about THE trip, and on a
|
|
111
|
+
// pair where six of ten coaches run non-stop there is no single
|
|
112
|
+
// itinerary to state; the page says "some buses also call at" there,
|
|
113
|
+
// and there is no way to say "some" in this property. Absent rather
|
|
114
|
+
// than approximated.
|
|
115
|
+
...(facts.via?.every ? {
|
|
116
|
+
itinerary: [
|
|
117
|
+
stop(facts.from, facts.from_country),
|
|
118
|
+
...facts.via.cities.map(city => ({ '@type': 'BusStop', name: city })),
|
|
119
|
+
stop(facts.to, facts.to_country)
|
|
120
|
+
]
|
|
121
|
+
} : {}),
|
|
77
122
|
|
|
78
123
|
// Edited: Claude - Date: 2026-08-20
|
|
79
124
|
// The pair's own score - see the header comment. Null below the
|
|
@@ -100,56 +145,23 @@ const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
|
|
|
100
145
|
'@type': 'Offer',
|
|
101
146
|
price: facts.price_from,
|
|
102
147
|
priceCurrency: facts.currency,
|
|
103
|
-
|
|
148
|
+
|
|
149
|
+
// Edited: Claude - Date: 2026-08-21
|
|
150
|
+
// The availability is now READ rather than asserted. This said
|
|
151
|
+
// InStock on every pair it was ever emitted for, including the
|
|
152
|
+
// ones whose every timetable window had closed - a machine-readable
|
|
153
|
+
// claim that a ticket could be bought, published about journeys
|
|
154
|
+
// that sell on no date a visitor can pick. OutOfStock is the same
|
|
155
|
+
// thing the page itself now says above the fare, which is the
|
|
156
|
+
// whole rule this file opens with: never state here what the page
|
|
157
|
+
// does not show.
|
|
158
|
+
availability: stopped(facts)
|
|
159
|
+
? 'https://schema.org/OutOfStock'
|
|
160
|
+
: 'https://schema.org/InStock'
|
|
104
161
|
}
|
|
105
162
|
} : {})
|
|
106
163
|
};
|
|
107
164
|
|
|
108
|
-
const questions: { q: string, a: string }[] = [];
|
|
109
|
-
|
|
110
|
-
const duration = (minutes: number): string => (
|
|
111
|
-
minutes % 60 === 0
|
|
112
|
-
? t('routes_order.facts.duration_hours', { hours: minutes / 60 })
|
|
113
|
-
: t('routes_order.facts.duration_value', { hours: Math.floor(minutes / 60), minutes: minutes % 60 })
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
if (facts.duration_min !== null && facts.duration_max !== null) {
|
|
117
|
-
questions.push({
|
|
118
|
-
q: t('routes_order.schema.duration_q', { from: facts.from, to: facts.to }),
|
|
119
|
-
a: facts.duration_min === facts.duration_max
|
|
120
|
-
? t('routes_order.schema.duration_a', { from: facts.from, to: facts.to, duration: duration(facts.duration_min) })
|
|
121
|
-
: t('routes_order.schema.duration_a_range', { from: facts.from, to: facts.to, min: duration(facts.duration_min), max: duration(facts.duration_max) })
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (facts.price_from_display) {
|
|
126
|
-
questions.push({
|
|
127
|
-
q: t('routes_order.schema.price_q', { from: facts.from, to: facts.to }),
|
|
128
|
-
a: t('routes_order.schema.price_a', { from: facts.from, to: facts.to, price: facts.price_from_display })
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (facts.departures > 0) {
|
|
133
|
-
questions.push({
|
|
134
|
-
q: t('routes_order.schema.departures_q', { from: facts.from, to: facts.to }),
|
|
135
|
-
a: t('routes_order.schema.departures_a', { count: facts.departures, from: facts.from, to: facts.to })
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (facts.departure_first && facts.departure_last) {
|
|
140
|
-
questions.push({
|
|
141
|
-
q: t('routes_order.schema.times_q', { from: facts.from, to: facts.to }),
|
|
142
|
-
a: t('routes_order.schema.times_a', { first: facts.departure_first, last: facts.departure_last })
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (facts.operators.length > 0) {
|
|
147
|
-
questions.push({
|
|
148
|
-
q: t('routes_order.schema.operators_q', { from: facts.from, to: facts.to }),
|
|
149
|
-
a: t('routes_order.facts.operators', { operators: facts.operators.join(', ') })
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
165
|
// Google requires a FAQPage to carry at least one question, and a page
|
|
154
166
|
// claiming to be an FAQ with nothing on it is worse than not claiming it.
|
|
155
167
|
const faq = questions.length > 0 ? {
|
package/Sections/Sections.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import Facts from '../Facts/Facts';
|
|
3
|
+
import Faq from '../Faq/Faq';
|
|
3
4
|
import Schedule from '../Schedule/Schedule';
|
|
4
5
|
import Links from '../Links/Links';
|
|
5
6
|
import Schema from './Schema';
|
|
6
7
|
import { useGetFacts } from '../Facts/services';
|
|
8
|
+
import buildQuestions from '../Facts/questions';
|
|
9
|
+
import { stopped } from '../Facts/service';
|
|
7
10
|
import { Nav, Anchor, Anchored } from './styles';
|
|
8
11
|
|
|
9
12
|
interface Props {
|
|
@@ -15,6 +18,7 @@ interface Props {
|
|
|
15
18
|
const TRIPS = 'trips';
|
|
16
19
|
const FACTS = 'facts';
|
|
17
20
|
const SCHEDULE = 'schedule';
|
|
21
|
+
const FAQ = 'faq';
|
|
18
22
|
const LINKS = 'links';
|
|
19
23
|
|
|
20
24
|
/**
|
|
@@ -40,20 +44,64 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
40
44
|
|
|
41
45
|
const facts = data?.facts;
|
|
42
46
|
|
|
43
|
-
const links = data?.links ?? { from: [], to: [] };
|
|
47
|
+
const links = data?.links ?? { from: [], to: [], reverse: null };
|
|
44
48
|
|
|
45
49
|
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
46
50
|
// The linking block survives a pair with no facts. A pair that cannot be
|
|
47
51
|
// sold is precisely where somebody most needs a way onward - dropping the
|
|
48
52
|
// links there would leave them on a dead end.
|
|
49
53
|
if (!facts) {
|
|
50
|
-
return links.from.length > 0 || links.to.length > 0
|
|
51
|
-
? <Links id={ LINKS } from={ links.from } to={ links.to } t={ t } />
|
|
54
|
+
return links.from.length > 0 || links.to.length > 0 || links.reverse
|
|
55
|
+
? <Links id={ LINKS } from={ links.from } to={ links.to } reverse={ links.reverse } t={ t } />
|
|
52
56
|
: null;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const schedule = facts.schedule ?? [];
|
|
56
60
|
|
|
61
|
+
// Edited: Claude - Date: 2026-08-20
|
|
62
|
+
// Built ONCE, here, and handed to both consumers. The FAQ section renders
|
|
63
|
+
// these strings and the FAQPage structured data quotes them; Google
|
|
64
|
+
// requires the two to be the same text and the only way to guarantee that
|
|
65
|
+
// across fifteen languages is for there to be one array, not two lists
|
|
66
|
+
// that happen to agree today.
|
|
67
|
+
const questions = buildQuestions(facts, t);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Edited: Claude - Date: 2026-08-21
|
|
71
|
+
*
|
|
72
|
+
* ON A PAIR THAT HAS STOPPED RUNNING, THE WAY OUT COMES FIRST. The facts
|
|
73
|
+
* block now says plainly that the route is not currently scheduled - and
|
|
74
|
+
* the moment it does, the most useful thing on the page is the list of
|
|
75
|
+
* nearby pairs that ARE running, not four hundred pixels of timetable
|
|
76
|
+
* the reader has just been told they cannot travel on.
|
|
77
|
+
*
|
|
78
|
+
* Hoisted rather than duplicated: it is the same <Links> element, moved,
|
|
79
|
+
* so a crawler still finds exactly one copy of each internal link. That
|
|
80
|
+
* is the whole reason this page keeps its ranking instead of being
|
|
81
|
+
* de-listed - an honest seasonal page that converts to a live journey is
|
|
82
|
+
* worth more than a 404, and it only converts if the link is where the
|
|
83
|
+
* reader is looking.
|
|
84
|
+
*
|
|
85
|
+
* Everything below stays exactly where it was. The timetable is still
|
|
86
|
+
* what the operator published and the FAQ still answers the questions
|
|
87
|
+
* this journey raises; neither is hidden, because a page that hides its
|
|
88
|
+
* content to admit a problem is thin content, which is what noindex is
|
|
89
|
+
* for and what this deliberately is not.
|
|
90
|
+
*/
|
|
91
|
+
const onward = (
|
|
92
|
+
<Links
|
|
93
|
+
id={ LINKS }
|
|
94
|
+
from={ links.from }
|
|
95
|
+
to={ links.to }
|
|
96
|
+
reverse={ links.reverse }
|
|
97
|
+
fromName={ facts.from }
|
|
98
|
+
toName={ facts.to }
|
|
99
|
+
t={ t }
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const dead = Boolean(stopped(facts));
|
|
104
|
+
|
|
57
105
|
return (
|
|
58
106
|
<>
|
|
59
107
|
<Nav aria-label={ t('routes_order.sections.label') }>
|
|
@@ -63,21 +111,36 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
63
111
|
{ schedule.length > 0 && (
|
|
64
112
|
<Anchor href={ `#${ SCHEDULE }` }>{ t('routes_order.sections.schedule') }</Anchor>
|
|
65
113
|
) }
|
|
114
|
+
|
|
115
|
+
{ questions.length > 0 && (
|
|
116
|
+
<Anchor href={ `#${ FAQ }` }>{ t('routes_order.sections.faq') }</Anchor>
|
|
117
|
+
) }
|
|
66
118
|
</Nav>
|
|
67
119
|
|
|
68
120
|
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
69
121
|
Roadmap Tier 3.5. Emits nothing the sections below do not also
|
|
70
122
|
show - same facts, same numbers, same operators. */ }
|
|
71
|
-
<Schema facts={ facts } t={ t } />
|
|
123
|
+
<Schema facts={ facts } questions={ questions } t={ t } />
|
|
72
124
|
|
|
73
125
|
<Anchored>
|
|
74
126
|
<Facts id={ FACTS } facts={ facts } t={ t } />
|
|
75
127
|
</Anchored>
|
|
76
128
|
|
|
129
|
+
{ dead && onward }
|
|
130
|
+
|
|
77
131
|
<Anchored>
|
|
78
132
|
<Schedule id={ SCHEDULE } from={ facts.from } to={ facts.to } rows={ schedule } t={ t } />
|
|
79
133
|
</Anchored>
|
|
80
134
|
|
|
135
|
+
{ /* Edited: Claude - Date: 2026-08-20
|
|
136
|
+
Below the timetable, because it is where somebody who has already
|
|
137
|
+
looked at the departures goes for the rest - and because the
|
|
138
|
+
answers reference the timetable rather than replace it. Renders
|
|
139
|
+
nothing when the pair's data supports no questions. */ }
|
|
140
|
+
<Anchored>
|
|
141
|
+
<Faq id={ FAQ } from={ facts.from } to={ facts.to } questions={ questions } t={ t } />
|
|
142
|
+
</Anchored>
|
|
143
|
+
|
|
81
144
|
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
82
145
|
NO reviews block here any more. A pair-level review wall sat in
|
|
83
146
|
the middle of a page whose job is to compare departures, where
|
|
@@ -89,14 +152,7 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
89
152
|
The `pair` mode of Reviews\BrowseController is untouched and
|
|
90
153
|
still works; nothing calls it from here. */ }
|
|
91
154
|
|
|
92
|
-
|
|
93
|
-
id={ LINKS }
|
|
94
|
-
from={ links.from }
|
|
95
|
-
to={ links.to }
|
|
96
|
-
fromName={ facts.from }
|
|
97
|
-
toName={ facts.to }
|
|
98
|
-
t={ t }
|
|
99
|
-
/>
|
|
155
|
+
{ !dead && onward }
|
|
100
156
|
</>
|
|
101
157
|
);
|
|
102
158
|
};
|