@autobusal/routes-order 1.21.1 → 1.22.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 +19 -0
- package/Facts/types.ts +3 -0
- package/Sections/Schema.tsx +148 -0
- package/Sections/Sections.tsx +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,25 @@ All notable changes to this package are documented here. This project follows
|
|
|
6
6
|
> Note: 1.8.0 was published without a changelog entry. The gap is left as-is
|
|
7
7
|
> rather than reconstructed after the fact.
|
|
8
8
|
|
|
9
|
+
## 1.22.0
|
|
10
|
+
|
|
11
|
+
Structured data on route-pair pages: `BusTrip` with its operators and
|
|
12
|
+
fares-from, and a `FAQPage` answering the five things people actually search
|
|
13
|
+
for — how long, how much, how many a day, when the first and last leave, and
|
|
14
|
+
who runs it.
|
|
15
|
+
|
|
16
|
+
One rule governs all of it: **never state anything the page does not show.**
|
|
17
|
+
Every value is the same one rendered in the facts block or the timetable a few
|
|
18
|
+
hundred pixels below. A rich result built on a claim a visitor cannot find is
|
|
19
|
+
the mismatch that gets a site's snippets pulled, and it is also just a lie told
|
|
20
|
+
to a machine.
|
|
21
|
+
|
|
22
|
+
The rating hangs off the **provider**, not the trip. An operator's rating is
|
|
23
|
+
earned across everything they run; attaching it to one city pair would claim
|
|
24
|
+
travellers rated this journey when they rated the company. Operators below the
|
|
25
|
+
review display threshold carry no rating in the markup, exactly as they carry
|
|
26
|
+
none on the page.
|
|
27
|
+
|
|
9
28
|
## 1.21.1
|
|
10
29
|
|
|
11
30
|
No horizontal scroll on a tablet's date strip. The 900px floor belongs to the
|
package/Facts/types.ts
CHANGED
|
@@ -6,6 +6,9 @@ export interface FactsData {
|
|
|
6
6
|
// formatted by the API with the brand's own currency - the PUBLIC settings
|
|
7
7
|
// payload carries no currency, so a frontend has nothing to format with
|
|
8
8
|
price_from_display: string | null
|
|
9
|
+
// ISO 4217, for schema.org's Offer - the display string has the symbol
|
|
10
|
+
// baked in and guessing a code back out of it would be confidently wrong
|
|
11
|
+
currency: string | null
|
|
9
12
|
// both ends of the range, in minutes
|
|
10
13
|
duration_min: number | null
|
|
11
14
|
duration_max: number | null
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { JsonLd } from '@autobusal/common';
|
|
3
|
+
import { FactsData } from '../Facts/types';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
facts: FactsData
|
|
7
|
+
t: TFunction<'common'>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Structured data for a route-pair page.
|
|
12
|
+
*
|
|
13
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
14
|
+
*
|
|
15
|
+
* Roadmap Tier 3.5, and the cheapest item in the tier because the page
|
|
16
|
+
* already computes everything it needs.
|
|
17
|
+
*
|
|
18
|
+
* ONE RULE GOVERNS ALL OF IT: never state something here that the page does
|
|
19
|
+
* not show. A rich result built on a claim a visitor cannot find is the
|
|
20
|
+
* mismatch that gets a site's snippets pulled, and it is also just a lie
|
|
21
|
+
* told to a machine. Every value below is the same value rendered in the
|
|
22
|
+
* facts block or the timetable a few hundred pixels down - the duration
|
|
23
|
+
* range, the fares-from, the departure count, the operators.
|
|
24
|
+
*
|
|
25
|
+
* The rating hangs off the PROVIDER, not the trip. An operator's rating is
|
|
26
|
+
* earned across everything they run; attaching it to one city pair would be
|
|
27
|
+
* claiming travellers rated this journey when they rated the company. When
|
|
28
|
+
* an operator has no published rating - below the display threshold - it is
|
|
29
|
+
* simply absent, exactly as it is on the page.
|
|
30
|
+
*/
|
|
31
|
+
const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
|
|
32
|
+
const schedule = facts.schedule ?? [];
|
|
33
|
+
|
|
34
|
+
// Deduplicated operators, each keeping whatever rating it actually has.
|
|
35
|
+
const providers = schedule.reduce<{ name: string, rating: { average: number, count: number } | null }[]>((all, row) => {
|
|
36
|
+
if (!row.operator || all.some(item => item.name === row.operator)) {
|
|
37
|
+
return all;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return [ ...all, { name: row.operator, rating: row.rating } ];
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const organisation = (name: string, rating: { average: number, count: number } | null) => ({
|
|
44
|
+
'@type': 'Organization',
|
|
45
|
+
name,
|
|
46
|
+
...(rating ? {
|
|
47
|
+
aggregateRating: {
|
|
48
|
+
'@type': 'AggregateRating',
|
|
49
|
+
ratingValue: rating.average,
|
|
50
|
+
reviewCount: rating.count,
|
|
51
|
+
bestRating: 5,
|
|
52
|
+
worstRating: 1
|
|
53
|
+
}
|
|
54
|
+
} : {})
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const trip = {
|
|
58
|
+
'@context': 'https://schema.org',
|
|
59
|
+
'@type': 'BusTrip',
|
|
60
|
+
name: t('routes_order.facts.title', { from: facts.from, to: facts.to }),
|
|
61
|
+
|
|
62
|
+
departureBusStop: { '@type': 'BusStop', name: facts.from },
|
|
63
|
+
arrivalBusStop: { '@type': 'BusStop', name: facts.to },
|
|
64
|
+
|
|
65
|
+
...(providers.length > 0 ? {
|
|
66
|
+
provider: providers.map(item => organisation(item.name, item.rating))
|
|
67
|
+
} : {}),
|
|
68
|
+
|
|
69
|
+
// Only with a real ISO 4217 code. `price_from_display` is a formatted
|
|
70
|
+
// string with the symbol baked in, and guessing a code from it would
|
|
71
|
+
// produce structured data that is confidently wrong.
|
|
72
|
+
...(facts.price_from !== null && facts.currency ? {
|
|
73
|
+
offers: {
|
|
74
|
+
'@type': 'Offer',
|
|
75
|
+
price: facts.price_from,
|
|
76
|
+
priceCurrency: facts.currency,
|
|
77
|
+
availability: 'https://schema.org/InStock'
|
|
78
|
+
}
|
|
79
|
+
} : {})
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const questions: { q: string, a: string }[] = [];
|
|
83
|
+
|
|
84
|
+
const duration = (minutes: number): string => (
|
|
85
|
+
minutes % 60 === 0
|
|
86
|
+
? t('routes_order.facts.duration_hours', { hours: minutes / 60 })
|
|
87
|
+
: t('routes_order.facts.duration_value', { hours: Math.floor(minutes / 60), minutes: minutes % 60 })
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (facts.duration_min !== null && facts.duration_max !== null) {
|
|
91
|
+
questions.push({
|
|
92
|
+
q: t('routes_order.schema.duration_q', { from: facts.from, to: facts.to }),
|
|
93
|
+
a: facts.duration_min === facts.duration_max
|
|
94
|
+
? t('routes_order.schema.duration_a', { from: facts.from, to: facts.to, duration: duration(facts.duration_min) })
|
|
95
|
+
: t('routes_order.schema.duration_a_range', { from: facts.from, to: facts.to, min: duration(facts.duration_min), max: duration(facts.duration_max) })
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (facts.price_from_display) {
|
|
100
|
+
questions.push({
|
|
101
|
+
q: t('routes_order.schema.price_q', { from: facts.from, to: facts.to }),
|
|
102
|
+
a: t('routes_order.schema.price_a', { from: facts.from, to: facts.to, price: facts.price_from_display })
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (facts.departures > 0) {
|
|
107
|
+
questions.push({
|
|
108
|
+
q: t('routes_order.schema.departures_q', { from: facts.from, to: facts.to }),
|
|
109
|
+
a: t('routes_order.schema.departures_a', { count: facts.departures, from: facts.from, to: facts.to })
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (facts.departure_first && facts.departure_last) {
|
|
114
|
+
questions.push({
|
|
115
|
+
q: t('routes_order.schema.times_q', { from: facts.from, to: facts.to }),
|
|
116
|
+
a: t('routes_order.schema.times_a', { first: facts.departure_first, last: facts.departure_last })
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (facts.operators.length > 0) {
|
|
121
|
+
questions.push({
|
|
122
|
+
q: t('routes_order.schema.operators_q', { from: facts.from, to: facts.to }),
|
|
123
|
+
a: t('routes_order.facts.operators', { operators: facts.operators.join(', ') })
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Google requires a FAQPage to carry at least one question, and a page
|
|
128
|
+
// claiming to be an FAQ with nothing on it is worse than not claiming it.
|
|
129
|
+
const faq = questions.length > 0 ? {
|
|
130
|
+
'@context': 'https://schema.org',
|
|
131
|
+
'@type': 'FAQPage',
|
|
132
|
+
mainEntity: questions.map(item => ({
|
|
133
|
+
'@type': 'Question',
|
|
134
|
+
name: item.q,
|
|
135
|
+
acceptedAnswer: { '@type': 'Answer', text: item.a }
|
|
136
|
+
}))
|
|
137
|
+
} : null;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
<JsonLd data={ trip } />
|
|
142
|
+
|
|
143
|
+
{ faq && <JsonLd data={ faq } /> }
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export default Schema;
|
package/Sections/Sections.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { TFunction } from 'i18next';
|
|
|
2
2
|
import Facts from '../Facts/Facts';
|
|
3
3
|
import Schedule from '../Schedule/Schedule';
|
|
4
4
|
import Links from '../Links/Links';
|
|
5
|
+
import Schema from './Schema';
|
|
5
6
|
import { useGetFacts } from '../Facts/services';
|
|
6
7
|
import { Nav, Anchor, Anchored } from './styles';
|
|
7
8
|
|
|
@@ -64,6 +65,11 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
64
65
|
) }
|
|
65
66
|
</Nav>
|
|
66
67
|
|
|
68
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
69
|
+
Roadmap Tier 3.5. Emits nothing the sections below do not also
|
|
70
|
+
show - same facts, same numbers, same operators. */ }
|
|
71
|
+
<Schema facts={ facts } t={ t } />
|
|
72
|
+
|
|
67
73
|
<Anchored>
|
|
68
74
|
<Facts id={ FACTS } facts={ facts } t={ t } />
|
|
69
75
|
</Anchored>
|