@autobusal/routes-order 1.21.0 → 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 CHANGED
@@ -6,6 +6,32 @@ 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
+
28
+ ## 1.21.1
29
+
30
+ No horizontal scroll on a tablet's date strip. The 900px floor belongs to the
31
+ seven-day layout, which only appears from 1024px — applied from 640px it made
32
+ a tablet showing five days scroll sideways for a row that fitted comfortably,
33
+ because the strip was reserving width for two cells it was not rendering.
34
+
9
35
  ## 1.21.0
10
36
 
11
37
  The same-day notice no longer hedges about timezones. obtapi now holds a zone
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
@@ -7,9 +7,18 @@ export const Container = styled.div`
7
7
  border-radius: ${ props => props.theme.borderRadius };
8
8
 
9
9
  @media (min-width: 640px) {
10
- overflow-x: auto;
11
10
  padding: 10px 15px;
12
11
  }
12
+
13
+ /**
14
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
15
+ * The scroll container only exists where the fixed-width strip below does,
16
+ * which is desktop. It was switched on from 640px, so a tablet inherited a
17
+ * scrollbar for a strip that had every reason to fit.
18
+ */
19
+ @media (min-width: 1024px) {
20
+ overflow-x: auto;
21
+ }
13
22
  `;
14
23
 
15
24
  /**
@@ -44,7 +53,6 @@ export const Inner = styled.div`
44
53
  }
45
54
 
46
55
  @media (min-width: 640px) {
47
- min-width: 900px;
48
56
  gap: 5px;
49
57
 
50
58
  & > button:first-child,
@@ -53,6 +61,18 @@ export const Inner = styled.div`
53
61
  height: auto;
54
62
  }
55
63
  }
64
+
65
+ /**
66
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
67
+ *
68
+ * The 900px floor belongs to the SEVEN-day strip, and seven days only
69
+ * appear from 1024px. Applied from 640px it forced a tablet showing FIVE
70
+ * days to scroll sideways for a row that fitted comfortably - the strip
71
+ * was reserving width for two cells that were not being rendered.
72
+ */
73
+ @media (min-width: 1024px) {
74
+ min-width: 900px;
75
+ }
56
76
  `;
57
77
 
58
78
  /**
@@ -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;
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"