@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/CHANGELOG.md +26 -0
- package/Checkout/Checkout.tsx +35 -1
- package/Facts/Facts.tsx +130 -15
- package/Facts/questions.ts +215 -0
- package/Facts/styles.ts +26 -0
- package/Facts/types.ts +61 -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 +47 -48
- package/Sections/Sections.tsx +29 -4
- package/Step4/Passenger/Passenger.tsx +196 -12
- package/Step4/Passengers/Display.tsx +6 -2
- package/Step4/Passengers/Passengers.tsx +16 -2
- package/Step4/Saved/Choose.tsx +99 -0
- package/Step4/Saved/Remember.tsx +58 -0
- package/Step4/Saved/styles.ts +87 -0
- package/package.json +1 -1
- package/services.ts +75 -1
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,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:
|
|
76
|
-
arrivalBusStop:
|
|
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 ? {
|
package/Sections/Sections.tsx
CHANGED
|
@@ -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 }
|