@autobusal/routes-order 1.32.0 → 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/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 }