@autobusal/routes-order 1.32.1 → 1.33.0

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.33.0
4
+
5
+ ### Added
6
+
7
+ - **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.
8
+ - 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.
9
+ - **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.
10
+ - **`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.
11
+ - **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.
12
+
13
+ ### Fixed
14
+
15
+ - **"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.
16
+ - **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.
17
+ - **"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.
18
+
3
19
  ## 1.32.1
4
20
 
5
21
  ### Added
package/Facts/Facts.tsx CHANGED
@@ -1,6 +1,7 @@
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, Operators, Prose } from './styles';
3
3
  import { FactsData } from './types';
4
+ import { DAYS, duration } from './questions';
4
5
 
5
6
  interface Props {
6
7
  id: string
@@ -29,21 +30,55 @@ interface Props {
29
30
  * Renders nothing at all when the API has no facts for the pair. An empty
30
31
  * facts block is worse than none - it is exactly the thin content this tier
31
32
  * exists to remove.
33
+ *
34
+ * Edited: Claude - Date: 2026-08-20
35
+ *
36
+ * It now also answers "what is this journey LIKE", which is the half a
37
+ * grid of numbers cannot: how often it runs, how many of the coaches are
38
+ * non-stop, how many travel overnight, where they call, what is on board,
39
+ * and whether it crosses a border. Sentences rather than more tiles,
40
+ * because that is the half a reader takes away and a machine can quote.
41
+ *
42
+ * The same restraint applies to all of it: a sentence whose data is
43
+ * missing is not printed, and one whose data supports only a weaker claim
44
+ * is printed in the weaker wording. A pair with one weekly coach ends up
45
+ * with a short honest block, not a padded one.
32
46
  */
33
47
  const Facts = ({ id, facts, t }: Props): JSX.Element => {
34
- // "07:30" built from a duration in minutes reads as a time of day, so the
35
- // hours and minutes are spelled out instead.
36
- const duration = (minutes: number): string => (
37
- t('routes_order.facts.duration_value', {
38
- hours: Math.floor(minutes / 60),
39
- minutes: minutes % 60
40
- })
41
- );
48
+ /**
49
+ * Edited: Claude - Date: 2026-08-20
50
+ *
51
+ * Whether this coach leaves the country, which is the first thing a
52
+ * traveller wants settled and the one fact here that is not about the
53
+ * timetable at all - it is about the two cities. Silent when either
54
+ * country is unknown rather than assuming the pair is domestic, and
55
+ * silent on the wording of what a border crossing REQUIRES: entry rules
56
+ * are the traveller's own and this page has no business inventing them.
57
+ */
58
+ const countries = (() => {
59
+ if (!facts.from_country || !facts.to_country) {
60
+ return null;
61
+ }
62
+
63
+ return facts.from_country === facts.to_country
64
+ ? <p>{ t('routes_order.facts.domestic', { country: facts.from_country }) }</p>
65
+ : <p>{ t('routes_order.facts.international', { from_country: facts.from_country, to_country: facts.to_country }) }</p>;
66
+ })();
67
+
68
+ const via = facts.via && facts.via.cities.length > 0 ? facts.via : null;
69
+
70
+ const amenities = facts.amenities && facts.amenities.names.length > 0 ? facts.amenities : null;
71
+
72
+ // Edited: Claude - Date: 2026-08-20
73
+ // A thin pair can have none of the four sentences below, and an empty
74
+ // <Prose> is still a box with a top margin - a gap under the grid that
75
+ // reads as something failing to load.
76
+ const prose = Boolean(countries || via || amenities || facts.operators.length > 0);
42
77
 
43
78
  const range = facts.duration_min !== null && facts.duration_max !== null
44
79
  ? (facts.duration_min === facts.duration_max
45
- ? duration(facts.duration_min)
46
- : `${ duration(facts.duration_min) } – ${ duration(facts.duration_max) }`)
80
+ ? duration(facts.duration_min, t)
81
+ : `${ duration(facts.duration_min, t) } – ${ duration(facts.duration_max, t) }`)
47
82
  : null;
48
83
 
49
84
  return (
@@ -84,6 +119,51 @@ const Facts = ({ id, facts, t }: Props): JSX.Element => {
84
119
  <Value>{ facts.departures }</Value>
85
120
  </Fact>
86
121
 
122
+ { /* Edited: Claude - Date: 2026-08-20
123
+ HOW OFTEN, which the departure count above cannot say - ten
124
+ services means something very different once a week than it
125
+ does every day. Absent entirely when no coach on the pair has a
126
+ usable timetable row, including the case that matters: a season
127
+ that has already ended. Saying nothing there is the honest
128
+ answer; "runs 3 times a week" about a coach that stopped
129
+ running last September is not. */ }
130
+ { facts.frequency && (
131
+ <Fact>
132
+ <Label>{ t('routes_order.facts.frequency') }</Label>
133
+
134
+ <Value>
135
+ { facts.frequency.daily
136
+ ? t('routes_order.facts.frequency_daily')
137
+ : facts.frequency.days.map(day => t(`data.days.${ DAYS[day] }`)).join(', ') }
138
+
139
+ <Note>
140
+ { facts.frequency.weekly === 1
141
+ ? t('routes_order.facts.frequency_weekly_single')
142
+ : t('routes_order.facts.frequency_weekly', { count: facts.frequency.weekly }) }
143
+ </Note>
144
+ </Value>
145
+ </Fact>
146
+ ) }
147
+
148
+ { /* Edited: Claude - Date: 2026-08-20
149
+ Counted out of the total rather than shown as a yes/no. "Direct
150
+ service: yes" on a pair where one coach in ten is direct is
151
+ true and useless; "1 of 10" is what a reader needs to know
152
+ before they start comparing departures. */ }
153
+ { facts.direct > 0 && (
154
+ <Fact>
155
+ <Label>{ t('routes_order.facts.direct') }</Label>
156
+ <Value>{ t('routes_order.facts.of_total', { count: facts.direct, total: facts.departures }) }</Value>
157
+ </Fact>
158
+ ) }
159
+
160
+ { facts.overnight > 0 && (
161
+ <Fact>
162
+ <Label>{ t('routes_order.facts.overnight') }</Label>
163
+ <Value>{ t('routes_order.facts.of_total', { count: facts.overnight, total: facts.departures }) }</Value>
164
+ </Fact>
165
+ ) }
166
+
87
167
  { facts.distance && (
88
168
  <Fact>
89
169
  <Label>{ t('routes_order.facts.distance') }</Label>
@@ -107,10 +187,45 @@ const Facts = ({ id, facts, t }: Props): JSX.Element => {
107
187
  ) }
108
188
  </Grid>
109
189
 
110
- { facts.operators.length > 0 && (
111
- <Operators>
112
- { t('routes_order.facts.operators', { operators: facts.operators.join(', ') }) }
113
- </Operators>
190
+ { /* Edited: Claude - Date: 2026-08-20
191
+ The part somebody reads rather than scans. Four sentences at most,
192
+ each one printed only when the data behind it exists, and none of
193
+ them a generality - a pair served by one weekly coach gets one
194
+ sentence and it is true, which is a better page than four
195
+ sentences of hedging.
196
+
197
+ EVERY/SOME IS THE WHOLE CARE HERE. Buses on one route are not
198
+ interchangeable: six of the ten Tirana-Thessaloniki coaches run
199
+ non-stop and they carry different equipment. "Every bus calls at
200
+ Korca" and "some buses also call at Korca" are different claims
201
+ and obtapi says which one the data supports, so the copy never
202
+ has to guess. */ }
203
+ { prose && (
204
+ <Prose>
205
+ { countries }
206
+
207
+ { via && (
208
+ <p>
209
+ { t(via.every ? 'routes_order.facts.via_every' : 'routes_order.facts.via_some', {
210
+ cities: via.cities.join(', ')
211
+ }) }
212
+ </p>
213
+ ) }
214
+
215
+ { amenities && (
216
+ <p>
217
+ { t(amenities.every ? 'routes_order.facts.amenities_every' : 'routes_order.facts.amenities_some', {
218
+ amenities: amenities.names.join(', ')
219
+ }) }
220
+ </p>
221
+ ) }
222
+
223
+ { facts.operators.length > 0 && (
224
+ <Operators>
225
+ { t('routes_order.facts.operators', { operators: facts.operators.join(', ') }) }
226
+ </Operators>
227
+ ) }
228
+ </Prose>
114
229
  ) }
115
230
  </Container>
116
231
  );
@@ -0,0 +1,215 @@
1
+ import { TFunction } from 'i18next';
2
+ import { FactsData } from './types';
3
+
4
+ export interface Question {
5
+ // stable enough to key a list on, and never rendered
6
+ id: string
7
+ q: string
8
+ a: string
9
+ }
10
+
11
+ /**
12
+ * ISO weekday to the short day names the rest of the site already ships in
13
+ * all fifteen languages (`data.days.*`) - "Mon, Wed, Fri" rather than a new
14
+ * set of long names nobody would have translated for a week.
15
+ */
16
+ export const DAYS: Record<number, string> = {
17
+ 1: 'mon', 2: 'tue', 3: 'wed', 4: 'thu', 5: 'fri', 6: 'sat', 7: 'sun'
18
+ };
19
+
20
+ /**
21
+ * The questions a pair page answers, and their answers.
22
+ *
23
+ * Edited: Claude - Date: 2026-08-20
24
+ *
25
+ * ONE BUILDER, TWO CONSUMERS, AND THAT IS THE ENTIRE POINT. The FAQPage
26
+ * structured data and the FAQ a human reads are built from this same array,
27
+ * so the schema's `acceptedAnswer` is the identical string the page renders
28
+ * a few hundred pixels away - character for character, in whatever language
29
+ * the reader is in. Google requires that match and pulls a site's rich
30
+ * results when it fails; two lists of hand-written questions kept in step by
31
+ * good intentions is exactly how it fails, six months later, in one locale
32
+ * nobody re-read.
33
+ *
34
+ * EVERY ANSWER IS A FACT obtapi COMPUTED, never an estimate and never a
35
+ * generality. A question whose data is missing is not asked: no "typically
36
+ * around 6 hours", no "usually several a day". A pair served by one coach a
37
+ * week gets four questions and they are all true, which is a better page
38
+ * than eight questions padded with hedges.
39
+ *
40
+ * Ordered by how often the question is actually typed - how long, how much,
41
+ * how often - rather than by how the payload happens to be shaped.
42
+ */
43
+ /**
44
+ * A journey length, in words.
45
+ *
46
+ * Edited: Claude - Date: 2026-08-20
47
+ *
48
+ * "07:30" built from a duration in minutes reads as a time of day, so the
49
+ * hours and minutes are spelled out - and a whole number of hours drops the
50
+ * "0m" rather than printing it.
51
+ *
52
+ * Exported because the facts block prints the same number a few hundred
53
+ * pixels from the FAQ answer that quotes it, and it had its own copy of
54
+ * this without the whole-hour rule: a three-hour coach read "3h 0m" in the
55
+ * grid and "3h" in the answer directly below it, on the same page, about
56
+ * the same bus.
57
+ */
58
+ export const duration = (minutes: number, t: TFunction<'common'>): string => (
59
+ minutes % 60 === 0
60
+ ? t('routes_order.facts.duration_hours', { hours: minutes / 60 })
61
+ : t('routes_order.facts.duration_value', { hours: Math.floor(minutes / 60), minutes: minutes % 60 })
62
+ );
63
+
64
+ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
65
+ const all: Question[] = [];
66
+
67
+ const cities = { from: facts.from, to: facts.to };
68
+
69
+ if (facts.duration_min !== null && facts.duration_max !== null) {
70
+ all.push({
71
+ id: 'duration',
72
+ q: t('routes_order.schema.duration_q', cities),
73
+ a: facts.duration_min === facts.duration_max
74
+ ? t('routes_order.schema.duration_a', { ...cities, duration: duration(facts.duration_min, t) })
75
+ : t('routes_order.schema.duration_a_range', { ...cities, min: duration(facts.duration_min, t), max: duration(facts.duration_max, t) })
76
+ });
77
+ }
78
+
79
+ if (facts.price_from_display) {
80
+ all.push({
81
+ id: 'price',
82
+ q: t('routes_order.schema.price_q', cities),
83
+
84
+ // A range only where there is one. On a pair whose services all cost
85
+ // the same, "from 15.00 EUR to 15.00 EUR" is a sentence that makes a
86
+ // reader distrust the rest of the page.
87
+ a: facts.price_to_display && facts.price_to !== facts.price_from
88
+ ? t('routes_order.schema.price_a_range', { ...cities, low: facts.price_from_display, high: facts.price_to_display })
89
+ : t('routes_order.schema.price_a', { ...cities, price: facts.price_from_display })
90
+ });
91
+ }
92
+
93
+ if (facts.frequency) {
94
+ const days = facts.frequency.days.map(day => t(`data.days.${ DAYS[day] }`)).join(', ');
95
+
96
+ all.push({
97
+ id: 'frequency',
98
+ q: t('routes_order.schema.frequency_q', cities),
99
+ a: facts.frequency.daily
100
+ ? t('routes_order.schema.frequency_a_daily', { ...cities, count: facts.frequency.weekly })
101
+ : (facts.frequency.weekly === 1
102
+ ? t('routes_order.schema.frequency_a_single', { ...cities, days })
103
+ : t('routes_order.schema.frequency_a', { ...cities, days, count: facts.frequency.weekly }))
104
+ });
105
+ }
106
+
107
+ if (facts.departures > 0) {
108
+ all.push({
109
+ id: 'departures',
110
+ q: t('routes_order.schema.departures_q', cities),
111
+
112
+ // SERVICES, not departures. `facts.departures` counts the distinct
113
+ // coaches in the timetable, and a single daily one of them makes
114
+ // seven departures a week - so beside the frequency answer above,
115
+ // the old wording ("10 scheduled departures" next to "70 a week")
116
+ // read as a contradiction rather than as two facts. Both strings
117
+ // were reworded; the number they print never changed.
118
+ //
119
+ // And "There are 1 scheduled departures" was the sentence a thin
120
+ // pair got for as long as this block has existed - a thin pair being
121
+ // most of them. A separate key rather than an i18next plural: the
122
+ // plural categories of the eight Slavic locales here are not
123
+ // something a single English-shaped rule gets right.
124
+ a: facts.departures === 1
125
+ ? t('routes_order.schema.departures_a_single', cities)
126
+ : t('routes_order.schema.departures_a', { ...cities, count: facts.departures })
127
+ });
128
+ }
129
+
130
+ if (facts.departure_first && facts.departure_last) {
131
+ all.push({
132
+ id: 'times',
133
+ q: t('routes_order.schema.times_q', cities),
134
+ a: t('routes_order.schema.times_a', { first: facts.departure_first, last: facts.departure_last })
135
+ });
136
+ }
137
+
138
+ // Asked only where the answer is a real count. `direct` is meaningless
139
+ // without a total to read it against, and `departures` is that total.
140
+ if (facts.departures > 0) {
141
+ all.push({
142
+ id: 'direct',
143
+ q: t('routes_order.schema.direct_q', cities),
144
+ a: facts.direct === 0
145
+ ? t('routes_order.schema.direct_a_none', cities)
146
+ : (facts.direct === facts.departures
147
+ ? t('routes_order.schema.direct_a_all', cities)
148
+ : t('routes_order.schema.direct_a', { ...cities, count: facts.direct, total: facts.departures }))
149
+ });
150
+ }
151
+
152
+ // Only when there IS a night service. "No, none of these run overnight" is
153
+ // a question asked to be answered no, which is padding.
154
+ if (facts.overnight > 0) {
155
+ all.push({
156
+ id: 'overnight',
157
+ q: t('routes_order.schema.overnight_q', cities),
158
+ a: facts.overnight === facts.departures
159
+ ? t('routes_order.schema.overnight_a_all', cities)
160
+ : t('routes_order.schema.overnight_a', { ...cities, count: facts.overnight, total: facts.departures })
161
+ });
162
+ }
163
+
164
+ if (facts.via && facts.via.cities.length > 0) {
165
+ all.push({
166
+ id: 'via',
167
+ q: t('routes_order.schema.via_q', cities),
168
+
169
+ // The same sentence the facts block shows, deliberately - it is the
170
+ // one wording that distinguishes "every coach calls here" from "one of
171
+ // them does", and the answer must not be a looser paraphrase of it.
172
+ a: t(facts.via.every ? 'routes_order.facts.via_every' : 'routes_order.facts.via_some', {
173
+ cities: facts.via.cities.join(', ')
174
+ })
175
+ });
176
+ }
177
+
178
+ if (facts.amenities && facts.amenities.names.length > 0) {
179
+ all.push({
180
+ id: 'amenities',
181
+ q: t('routes_order.schema.amenities_q', cities),
182
+ a: t(facts.amenities.every ? 'routes_order.facts.amenities_every' : 'routes_order.facts.amenities_some', {
183
+ amenities: facts.amenities.names.join(', ')
184
+ })
185
+ });
186
+ }
187
+
188
+ if (facts.operators.length > 0) {
189
+ all.push({
190
+ id: 'operators',
191
+ q: t('routes_order.schema.operators_q', cities),
192
+ a: t('routes_order.facts.operators', { operators: facts.operators.join(', ') })
193
+ });
194
+ }
195
+
196
+ if (facts.distance) {
197
+ all.push({
198
+ id: 'distance',
199
+ q: t('routes_order.schema.distance_q', cities),
200
+
201
+ // The approximation gets its own sentence rather than the same one
202
+ // with a hedge bolted on. It is a sum of straight lines and it is
203
+ // ALWAYS short - 309 km against 412 km of tarmac on
204
+ // Tirana-Thessaloniki - so an answer that called it the road distance
205
+ // "approximately" would be wrong by a quarter on every pair.
206
+ a: facts.distance.road !== null
207
+ ? t('routes_order.schema.distance_a', { ...cities, km: facts.distance.road })
208
+ : t('routes_order.schema.distance_a_approx', { ...cities, km: facts.distance.approx })
209
+ });
210
+ }
211
+
212
+ return all;
213
+ };
214
+
215
+ export default questions;
package/Facts/styles.ts CHANGED
@@ -46,3 +46,29 @@ 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
+ `;
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,34 @@ 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
+ // how many of the `departures` run non-stop, and how many are still
63
+ // rolling the next morning - counted out of the total rather than reported
64
+ // as a yes/no, because "one of ten is direct" is the honest version
65
+ direct: number
66
+ overnight: number
67
+ // the cities in between, across every service on the pair - `every` says
68
+ // whether all of them call at all of these, which on a busy pair they
69
+ // usually do not
70
+ via: (FactsList & { cities: string[] }) | null
71
+ // what is on board, resolved to display names by obtapi (the ids are
72
+ // inventory keys and the names come from its lang files)
73
+ amenities: (FactsList & { names: string[] }) | null
20
74
  // Edited: Claude - Date: 2026-08-20
21
75
  // The pair's own review score - the published reviews of every route in
22
76
  // this very timetable, aggregated by obtapi with the same threshold rule
@@ -73,5 +127,11 @@ export interface FactsResponse {
73
127
  facts: FactsData | null
74
128
  // Tier 3.4 - pairs leaving the origin, and pairs arriving at the
75
129
  // destination. Present even when `facts` is null.
76
- links: { from: LinkRow[], to: LinkRow[] }
130
+ //
131
+ // Edited: Claude - Date: 2026-08-20
132
+ // `reverse` is the journey home. Pairs are directional, so it is a
133
+ // different page with its own timetable and fares - and it belongs to
134
+ // neither of the two blocks above, being a pair from the DESTINATION back
135
+ // to the origin. Null when the return leg is not sold.
136
+ links: { from: LinkRow[], to: LinkRow[], reverse: LinkRow | null }
77
137
  }
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
+ `;
@@ -1,9 +1,17 @@
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';
4
5
 
5
6
  interface Props {
6
7
  facts: FactsData
8
+ // Edited: Claude - Date: 2026-08-20
9
+ // Built by Facts/questions and handed in, because the FAQ section a few
10
+ // hundred pixels down the page renders this same array. Google requires
11
+ // the answer in the markup to be the answer on the page, and the only way
12
+ // to guarantee that in fifteen languages is for there to be one string,
13
+ // not two that match.
14
+ questions: Question[]
7
15
  t: TFunction<'common'>
8
16
  }
9
17
 
@@ -41,7 +49,7 @@ interface Props {
41
49
  * emitted here - an AggregateRating of nothing is a guideline violation,
42
50
  * worse than none.
43
51
  */
44
- const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
52
+ const Schema = ({ facts, questions, t }: Props): (JSX.Element | null) => {
45
53
  const schedule = facts.schedule ?? [];
46
54
 
47
55
  // Deduplicated operators, each keeping whatever rating it actually has.
@@ -67,13 +75,49 @@ const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
67
75
  } : {})
68
76
  });
69
77
 
78
+ /**
79
+ * Edited: Claude - Date: 2026-08-20
80
+ *
81
+ * A BusStop with its town and country on it, where obtapi knows the
82
+ * country. Two cities called Veria in two countries are one string to a
83
+ * machine and two different places to a traveller, and the pair page now
84
+ * says which country each end is in - so the markup can too without
85
+ * claiming anything the page does not.
86
+ */
87
+ const stop = (name: string, country: string | null) => ({
88
+ '@type': 'BusStop',
89
+ name,
90
+ ...(country ? {
91
+ address: {
92
+ '@type': 'PostalAddress',
93
+ addressLocality: name,
94
+ addressCountry: country
95
+ }
96
+ } : {})
97
+ });
98
+
70
99
  const trip = {
71
100
  '@context': 'https://schema.org',
72
101
  '@type': 'BusTrip',
73
102
  name: t('routes_order.facts.title', { from: facts.from, to: facts.to }),
74
103
 
75
- departureBusStop: { '@type': 'BusStop', name: facts.from },
76
- arrivalBusStop: { '@type': 'BusStop', name: facts.to },
104
+ departureBusStop: stop(facts.from, facts.from_country),
105
+ arrivalBusStop: stop(facts.to, facts.to_country),
106
+
107
+ // Edited: Claude - Date: 2026-08-20
108
+ // The stops in between - but ONLY when every service on the pair calls
109
+ // at all of them. An itinerary is a statement about THE trip, and on a
110
+ // pair where six of ten coaches run non-stop there is no single
111
+ // itinerary to state; the page says "some buses also call at" there,
112
+ // and there is no way to say "some" in this property. Absent rather
113
+ // than approximated.
114
+ ...(facts.via?.every ? {
115
+ itinerary: [
116
+ stop(facts.from, facts.from_country),
117
+ ...facts.via.cities.map(city => ({ '@type': 'BusStop', name: city })),
118
+ stop(facts.to, facts.to_country)
119
+ ]
120
+ } : {}),
77
121
 
78
122
  // Edited: Claude - Date: 2026-08-20
79
123
  // The pair's own score - see the header comment. Null below the
@@ -105,51 +149,6 @@ const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
105
149
  } : {})
106
150
  };
107
151
 
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
152
  // Google requires a FAQPage to carry at least one question, and a page
154
153
  // claiming to be an FAQ with nothing on it is worse than not claiming it.
155
154
  const faq = questions.length > 0 ? {
@@ -1,9 +1,11 @@
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';
7
9
  import { Nav, Anchor, Anchored } from './styles';
8
10
 
9
11
  interface Props {
@@ -15,6 +17,7 @@ interface Props {
15
17
  const TRIPS = 'trips';
16
18
  const FACTS = 'facts';
17
19
  const SCHEDULE = 'schedule';
20
+ const FAQ = 'faq';
18
21
  const LINKS = 'links';
19
22
 
20
23
  /**
@@ -40,20 +43,28 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
40
43
 
41
44
  const facts = data?.facts;
42
45
 
43
- const links = data?.links ?? { from: [], to: [] };
46
+ const links = data?.links ?? { from: [], to: [], reverse: null };
44
47
 
45
48
  // Edited: Ferjolt Ozuni - Date: 2026-08-03
46
49
  // The linking block survives a pair with no facts. A pair that cannot be
47
50
  // sold is precisely where somebody most needs a way onward - dropping the
48
51
  // links there would leave them on a dead end.
49
52
  if (!facts) {
50
- return links.from.length > 0 || links.to.length > 0
51
- ? <Links id={ LINKS } from={ links.from } to={ links.to } t={ t } />
53
+ return links.from.length > 0 || links.to.length > 0 || links.reverse
54
+ ? <Links id={ LINKS } from={ links.from } to={ links.to } reverse={ links.reverse } t={ t } />
52
55
  : null;
53
56
  }
54
57
 
55
58
  const schedule = facts.schedule ?? [];
56
59
 
60
+ // Edited: Claude - Date: 2026-08-20
61
+ // Built ONCE, here, and handed to both consumers. The FAQ section renders
62
+ // these strings and the FAQPage structured data quotes them; Google
63
+ // requires the two to be the same text and the only way to guarantee that
64
+ // across fifteen languages is for there to be one array, not two lists
65
+ // that happen to agree today.
66
+ const questions = buildQuestions(facts, t);
67
+
57
68
  return (
58
69
  <>
59
70
  <Nav aria-label={ t('routes_order.sections.label') }>
@@ -63,12 +74,16 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
63
74
  { schedule.length > 0 && (
64
75
  <Anchor href={ `#${ SCHEDULE }` }>{ t('routes_order.sections.schedule') }</Anchor>
65
76
  ) }
77
+
78
+ { questions.length > 0 && (
79
+ <Anchor href={ `#${ FAQ }` }>{ t('routes_order.sections.faq') }</Anchor>
80
+ ) }
66
81
  </Nav>
67
82
 
68
83
  { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
69
84
  Roadmap Tier 3.5. Emits nothing the sections below do not also
70
85
  show - same facts, same numbers, same operators. */ }
71
- <Schema facts={ facts } t={ t } />
86
+ <Schema facts={ facts } questions={ questions } t={ t } />
72
87
 
73
88
  <Anchored>
74
89
  <Facts id={ FACTS } facts={ facts } t={ t } />
@@ -78,6 +93,15 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
78
93
  <Schedule id={ SCHEDULE } from={ facts.from } to={ facts.to } rows={ schedule } t={ t } />
79
94
  </Anchored>
80
95
 
96
+ { /* Edited: Claude - Date: 2026-08-20
97
+ Below the timetable, because it is where somebody who has already
98
+ looked at the departures goes for the rest - and because the
99
+ answers reference the timetable rather than replace it. Renders
100
+ nothing when the pair's data supports no questions. */ }
101
+ <Anchored>
102
+ <Faq id={ FAQ } from={ facts.from } to={ facts.to } questions={ questions } t={ t } />
103
+ </Anchored>
104
+
81
105
  { /* Edited: Ferjolt Ozuni - Date: 2026-08-05
82
106
  NO reviews block here any more. A pair-level review wall sat in
83
107
  the middle of a page whose job is to compare departures, where
@@ -93,6 +117,7 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
93
117
  id={ LINKS }
94
118
  from={ links.from }
95
119
  to={ links.to }
120
+ reverse={ links.reverse }
96
121
  fromName={ facts.from }
97
122
  toName={ facts.to }
98
123
  t={ t }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.32.1",
3
+ "version": "1.33.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"