@autobusal/routes-order 1.9.0 → 1.9.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +673 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +59 -5
  14. package/Found/Dates/styles.ts +137 -7
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +10 -7
  18. package/Found/Filters/styles.ts +79 -8
  19. package/Found/Found.tsx +128 -29
  20. package/Found/Group/Group.tsx +3 -1
  21. package/Found/Header/styles.ts +20 -4
  22. package/Found/Orders/Orders.tsx +25 -4
  23. package/Found/Picks/Picks.tsx +10 -3
  24. package/Found/Picks/styles.ts +60 -9
  25. package/Found/Route/Route.tsx +189 -33
  26. package/Found/Route/styles.ts +488 -50
  27. package/Found/SameDay/SameDay.tsx +35 -0
  28. package/Found/SameDay/styles.ts +27 -0
  29. package/Found/Sort/styles.ts +52 -7
  30. package/Found/Summary/Summary.tsx +1 -1
  31. package/Found/Summary/styles.ts +12 -0
  32. package/Found/Watch/Watch.tsx +187 -0
  33. package/Found/Watch/styles.ts +118 -0
  34. package/Found/refine.ts +69 -0
  35. package/Links/Links.tsx +91 -0
  36. package/Links/styles.ts +75 -0
  37. package/RoutesOrder.tsx +239 -31
  38. package/Schedule/Schedule.tsx +123 -0
  39. package/Schedule/styles.ts +71 -0
  40. package/Sections/Schema.tsx +186 -0
  41. package/Sections/Sections.tsx +160 -0
  42. package/Sections/styles.ts +44 -0
  43. package/Step1/prepare.ts +21 -2
  44. package/Step2.tsx +34 -1
  45. package/Step4/Passenger/Passenger.tsx +253 -24
  46. package/Step4/Passenger/styles.ts +21 -1
  47. package/Step4/Passengers/Display.tsx +10 -2
  48. package/Step4/Passengers/Passengers.tsx +53 -4
  49. package/Step4/Saved/Choose.tsx +99 -0
  50. package/Step4/Saved/Remember.tsx +58 -0
  51. package/Step4/Saved/styles.ts +87 -0
  52. package/Step4/checkAge.ts +15 -4
  53. package/Step5/Addons/Addons.tsx +146 -4
  54. package/Step5/Addons/styles.ts +94 -0
  55. package/Step5/Billing/Billing.tsx +138 -70
  56. package/Step5/Coupons/Coupons.tsx +28 -18
  57. package/Step5/Coupons/styles.ts +41 -8
  58. package/Step5/Payments/Payments.tsx +36 -5
  59. package/Step5/Payments/styles.ts +16 -7
  60. package/Step5/Summary/About/About.tsx +99 -27
  61. package/Step5/Summary/About/styles.ts +118 -1
  62. package/Step5/Summary/styles.ts +12 -5
  63. package/Step5/utilities.ts +67 -12
  64. package/Steps/Steps.tsx +3 -1
  65. package/commerce.ts +24 -0
  66. package/package.json +1 -1
  67. package/services.ts +295 -12
  68. package/styles.ts +126 -0
  69. package/types.ts +78 -1
  70. package/Found/Booking/Booking.tsx +0 -61
  71. package/Found/Booking/styles.ts +0 -50
  72. package/Step4/Step4.tsx +0 -114
  73. package/Step5/Step5.tsx +0 -185
@@ -0,0 +1,186 @@
1
+ import { TFunction } from 'i18next';
2
+ import { JsonLd } from '@autobusal/common';
3
+ import { FactsData } from '../Facts/types';
4
+ import { Question } from '../Facts/questions';
5
+ import { stopped } from '../Facts/service';
6
+
7
+ interface Props {
8
+ facts: FactsData
9
+ // Edited: Claude - Date: 2026-08-20
10
+ // Built by Facts/questions and handed in, because the FAQ section a few
11
+ // hundred pixels down the page renders this same array. Google requires
12
+ // the answer in the markup to be the answer on the page, and the only way
13
+ // to guarantee that in fifteen languages is for there to be one string,
14
+ // not two that match.
15
+ questions: Question[]
16
+ t: TFunction<'common'>
17
+ }
18
+
19
+ /**
20
+ * Structured data for a route-pair page.
21
+ *
22
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
23
+ *
24
+ * Roadmap Tier 3.5, and the cheapest item in the tier because the page
25
+ * already computes everything it needs.
26
+ *
27
+ * ONE RULE GOVERNS ALL OF IT: never state something here that the page does
28
+ * not show. A rich result built on a claim a visitor cannot find is the
29
+ * mismatch that gets a site's snippets pulled, and it is also just a lie
30
+ * told to a machine. Every value below is the same value rendered in the
31
+ * facts block or the timetable a few hundred pixels down - the duration
32
+ * range, the fares-from, the departure count, the operators.
33
+ *
34
+ * The rating hangs off the PROVIDER, not the trip. An operator's rating is
35
+ * earned across everything they run; attaching it to one city pair would be
36
+ * claiming travellers rated this journey when they rated the company. When
37
+ * an operator has no published rating - below the display threshold - it is
38
+ * simply absent, exactly as it is on the page.
39
+ *
40
+ * Edited: Claude - Date: 2026-08-20
41
+ *
42
+ * The trip now ALSO carries its own AggregateRating - and that is not the
43
+ * claim the paragraph above forbids, because `facts.rating` is a different
44
+ * number: obtapi aggregates the published reviews of exactly the routes in
45
+ * this timetable (Facts::rating, the same set Reviews\BrowseController's
46
+ * `pair` mode reads), not a company-wide score stretched over one leg.
47
+ * Travellers who booked these very coaches wrote those reviews, and every
48
+ * card on the page links to the operator profile where they are read in
49
+ * full. Below the display threshold obtapi sends null and nothing is
50
+ * emitted here - an AggregateRating of nothing is a guideline violation,
51
+ * worse than none.
52
+ */
53
+ const Schema = ({ facts, questions, t }: Props): (JSX.Element | null) => {
54
+ const schedule = facts.schedule ?? [];
55
+
56
+ // Deduplicated operators, each keeping whatever rating it actually has.
57
+ const providers = schedule.reduce<{ name: string, rating: { average: number, count: number } | null }[]>((all, row) => {
58
+ if (!row.operator || all.some(item => item.name === row.operator)) {
59
+ return all;
60
+ }
61
+
62
+ return [ ...all, { name: row.operator, rating: row.rating } ];
63
+ }, []);
64
+
65
+ const organisation = (name: string, rating: { average: number, count: number } | null) => ({
66
+ '@type': 'Organization',
67
+ name,
68
+ ...(rating ? {
69
+ aggregateRating: {
70
+ '@type': 'AggregateRating',
71
+ ratingValue: rating.average,
72
+ reviewCount: rating.count,
73
+ bestRating: 5,
74
+ worstRating: 1
75
+ }
76
+ } : {})
77
+ });
78
+
79
+ /**
80
+ * Edited: Claude - Date: 2026-08-20
81
+ *
82
+ * A BusStop with its town and country on it, where obtapi knows the
83
+ * country. Two cities called Veria in two countries are one string to a
84
+ * machine and two different places to a traveller, and the pair page now
85
+ * says which country each end is in - so the markup can too without
86
+ * claiming anything the page does not.
87
+ */
88
+ const stop = (name: string, country: string | null) => ({
89
+ '@type': 'BusStop',
90
+ name,
91
+ ...(country ? {
92
+ address: {
93
+ '@type': 'PostalAddress',
94
+ addressLocality: name,
95
+ addressCountry: country
96
+ }
97
+ } : {})
98
+ });
99
+
100
+ const trip = {
101
+ '@context': 'https://schema.org',
102
+ '@type': 'BusTrip',
103
+ name: t('routes_order.facts.title', { from: facts.from, to: facts.to }),
104
+
105
+ departureBusStop: stop(facts.from, facts.from_country),
106
+ arrivalBusStop: stop(facts.to, facts.to_country),
107
+
108
+ // Edited: Claude - Date: 2026-08-20
109
+ // The stops in between - but ONLY when every service on the pair calls
110
+ // at all of them. An itinerary is a statement about THE trip, and on a
111
+ // pair where six of ten coaches run non-stop there is no single
112
+ // itinerary to state; the page says "some buses also call at" there,
113
+ // and there is no way to say "some" in this property. Absent rather
114
+ // than approximated.
115
+ ...(facts.via?.every ? {
116
+ itinerary: [
117
+ stop(facts.from, facts.from_country),
118
+ ...facts.via.cities.map(city => ({ '@type': 'BusStop', name: city })),
119
+ stop(facts.to, facts.to_country)
120
+ ]
121
+ } : {}),
122
+
123
+ // Edited: Claude - Date: 2026-08-20
124
+ // The pair's own score - see the header comment. Null below the
125
+ // display threshold, and then the property simply does not exist.
126
+ ...(facts.rating ? {
127
+ aggregateRating: {
128
+ '@type': 'AggregateRating',
129
+ ratingValue: facts.rating.average,
130
+ reviewCount: facts.rating.count,
131
+ bestRating: 5,
132
+ worstRating: 1
133
+ }
134
+ } : {}),
135
+
136
+ ...(providers.length > 0 ? {
137
+ provider: providers.map(item => organisation(item.name, item.rating))
138
+ } : {}),
139
+
140
+ // Only with a real ISO 4217 code. `price_from_display` is a formatted
141
+ // string with the symbol baked in, and guessing a code from it would
142
+ // produce structured data that is confidently wrong.
143
+ ...(facts.price_from !== null && facts.currency ? {
144
+ offers: {
145
+ '@type': 'Offer',
146
+ price: facts.price_from,
147
+ priceCurrency: facts.currency,
148
+
149
+ // Edited: Claude - Date: 2026-08-21
150
+ // The availability is now READ rather than asserted. This said
151
+ // InStock on every pair it was ever emitted for, including the
152
+ // ones whose every timetable window had closed - a machine-readable
153
+ // claim that a ticket could be bought, published about journeys
154
+ // that sell on no date a visitor can pick. OutOfStock is the same
155
+ // thing the page itself now says above the fare, which is the
156
+ // whole rule this file opens with: never state here what the page
157
+ // does not show.
158
+ availability: stopped(facts)
159
+ ? 'https://schema.org/OutOfStock'
160
+ : 'https://schema.org/InStock'
161
+ }
162
+ } : {})
163
+ };
164
+
165
+ // Google requires a FAQPage to carry at least one question, and a page
166
+ // claiming to be an FAQ with nothing on it is worse than not claiming it.
167
+ const faq = questions.length > 0 ? {
168
+ '@context': 'https://schema.org',
169
+ '@type': 'FAQPage',
170
+ mainEntity: questions.map(item => ({
171
+ '@type': 'Question',
172
+ name: item.q,
173
+ acceptedAnswer: { '@type': 'Answer', text: item.a }
174
+ }))
175
+ } : null;
176
+
177
+ return (
178
+ <>
179
+ <JsonLd data={ trip } />
180
+
181
+ { faq && <JsonLd data={ faq } /> }
182
+ </>
183
+ );
184
+ };
185
+
186
+ export default Schema;
@@ -0,0 +1,160 @@
1
+ import { TFunction } from 'i18next';
2
+ import Facts from '../Facts/Facts';
3
+ import Faq from '../Faq/Faq';
4
+ import Schedule from '../Schedule/Schedule';
5
+ import Links from '../Links/Links';
6
+ import Schema from './Schema';
7
+ import { useGetFacts } from '../Facts/services';
8
+ import buildQuestions from '../Facts/questions';
9
+ import { stopped } from '../Facts/service';
10
+ import { Nav, Anchor, Anchored } from './styles';
11
+
12
+ interface Props {
13
+ from?: string
14
+ to?: string
15
+ t: TFunction<'common'>
16
+ }
17
+
18
+ const TRIPS = 'trips';
19
+ const FACTS = 'facts';
20
+ const SCHEDULE = 'schedule';
21
+ const FAQ = 'faq';
22
+ const LINKS = 'links';
23
+
24
+ /**
25
+ * Everything on a route-pair page that is not the booking wizard.
26
+ *
27
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
28
+ *
29
+ * Roadmap Tier 3.1. The wizard stays exactly where it is and keeps working
30
+ * exactly as it did - this is a layout around it, not a rewrite of the
31
+ * funnel. Somebody who arrived ready to book still books; somebody who
32
+ * arrived from a search engine asking "how long does that take" now has an
33
+ * answer without scrolling through a form first.
34
+ *
35
+ * ONE fetch for both sections. Facts and Schedule are built from the same
36
+ * response, and letting each fetch its own would have doubled what the
37
+ * prerender crawl costs on every pair in the sitemap.
38
+ *
39
+ * The nav renders only when there is more than the wizard to navigate to -
40
+ * a bar whose only link is "Trips" is furniture.
41
+ */
42
+ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
43
+ const { data } = useGetFacts(from, to);
44
+
45
+ const facts = data?.facts;
46
+
47
+ const links = data?.links ?? { from: [], to: [], reverse: null };
48
+
49
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
50
+ // The linking block survives a pair with no facts. A pair that cannot be
51
+ // sold is precisely where somebody most needs a way onward - dropping the
52
+ // links there would leave them on a dead end.
53
+ if (!facts) {
54
+ return links.from.length > 0 || links.to.length > 0 || links.reverse
55
+ ? <Links id={ LINKS } from={ links.from } to={ links.to } reverse={ links.reverse } t={ t } />
56
+ : null;
57
+ }
58
+
59
+ const schedule = facts.schedule ?? [];
60
+
61
+ // Edited: Claude - Date: 2026-08-20
62
+ // Built ONCE, here, and handed to both consumers. The FAQ section renders
63
+ // these strings and the FAQPage structured data quotes them; Google
64
+ // requires the two to be the same text and the only way to guarantee that
65
+ // across fifteen languages is for there to be one array, not two lists
66
+ // that happen to agree today.
67
+ const questions = buildQuestions(facts, t);
68
+
69
+ /**
70
+ * Edited: Claude - Date: 2026-08-21
71
+ *
72
+ * ON A PAIR THAT HAS STOPPED RUNNING, THE WAY OUT COMES FIRST. The facts
73
+ * block now says plainly that the route is not currently scheduled - and
74
+ * the moment it does, the most useful thing on the page is the list of
75
+ * nearby pairs that ARE running, not four hundred pixels of timetable
76
+ * the reader has just been told they cannot travel on.
77
+ *
78
+ * Hoisted rather than duplicated: it is the same <Links> element, moved,
79
+ * so a crawler still finds exactly one copy of each internal link. That
80
+ * is the whole reason this page keeps its ranking instead of being
81
+ * de-listed - an honest seasonal page that converts to a live journey is
82
+ * worth more than a 404, and it only converts if the link is where the
83
+ * reader is looking.
84
+ *
85
+ * Everything below stays exactly where it was. The timetable is still
86
+ * what the operator published and the FAQ still answers the questions
87
+ * this journey raises; neither is hidden, because a page that hides its
88
+ * content to admit a problem is thin content, which is what noindex is
89
+ * for and what this deliberately is not.
90
+ */
91
+ const onward = (
92
+ <Links
93
+ id={ LINKS }
94
+ from={ links.from }
95
+ to={ links.to }
96
+ reverse={ links.reverse }
97
+ fromName={ facts.from }
98
+ toName={ facts.to }
99
+ t={ t }
100
+ />
101
+ );
102
+
103
+ const dead = Boolean(stopped(facts));
104
+
105
+ return (
106
+ <>
107
+ <Nav aria-label={ t('routes_order.sections.label') }>
108
+ <Anchor href={ `#${ TRIPS }` }>{ t('routes_order.sections.trips') }</Anchor>
109
+ <Anchor href={ `#${ FACTS }` }>{ t('routes_order.sections.facts') }</Anchor>
110
+
111
+ { schedule.length > 0 && (
112
+ <Anchor href={ `#${ SCHEDULE }` }>{ t('routes_order.sections.schedule') }</Anchor>
113
+ ) }
114
+
115
+ { questions.length > 0 && (
116
+ <Anchor href={ `#${ FAQ }` }>{ t('routes_order.sections.faq') }</Anchor>
117
+ ) }
118
+ </Nav>
119
+
120
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
121
+ Roadmap Tier 3.5. Emits nothing the sections below do not also
122
+ show - same facts, same numbers, same operators. */ }
123
+ <Schema facts={ facts } questions={ questions } t={ t } />
124
+
125
+ <Anchored>
126
+ <Facts id={ FACTS } facts={ facts } t={ t } />
127
+ </Anchored>
128
+
129
+ { dead && onward }
130
+
131
+ <Anchored>
132
+ <Schedule id={ SCHEDULE } from={ facts.from } to={ facts.to } rows={ schedule } t={ t } />
133
+ </Anchored>
134
+
135
+ { /* Edited: Claude - Date: 2026-08-20
136
+ Below the timetable, because it is where somebody who has already
137
+ looked at the departures goes for the rest - and because the
138
+ answers reference the timetable rather than replace it. Renders
139
+ nothing when the pair's data supports no questions. */ }
140
+ <Anchored>
141
+ <Faq id={ FAQ } from={ facts.from } to={ facts.to } questions={ questions } t={ t } />
142
+ </Anchored>
143
+
144
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-05
145
+ NO reviews block here any more. A pair-level review wall sat in
146
+ the middle of a page whose job is to compare departures, where
147
+ what a reader needs about reputation is one number per operator -
148
+ and the result cards already carry exactly that.
149
+
150
+ The reviews themselves live on the operator's own page, which is
151
+ where somebody who wants to read them has gone looking for them.
152
+ The `pair` mode of Reviews\BrowseController is untouched and
153
+ still works; nothing calls it from here. */ }
154
+
155
+ { !dead && onward }
156
+ </>
157
+ );
158
+ };
159
+
160
+ export default Sections;
@@ -0,0 +1,44 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * The in-page nav.
5
+ *
6
+ * Sticky, because the sections below it are long and the whole point is to
7
+ * let somebody who landed from a search engine jump to the part they came
8
+ * for - a timetable, usually - without scrolling past a booking wizard they
9
+ * have not decided to use yet.
10
+ */
11
+ export const Nav = styled.nav`
12
+ position: sticky;
13
+ top: 0;
14
+ z-index: 20;
15
+ display: flex;
16
+ gap: 6px;
17
+ overflow-x: auto;
18
+ margin-top: 25px;
19
+ padding: 8px 10px;
20
+ border-radius: ${ props => props.theme.borderRadius };
21
+ background: ${ props => props.theme.background.normal };
22
+ box-shadow: 0 2px 10px rgba(0, 0, 0, .06);
23
+ `;
24
+
25
+ export const Anchor = styled.a`
26
+ padding: 6px 14px;
27
+ border-radius: ${ props => props.theme.borderRadius };
28
+ font-size: ${ props => props.theme.size.s };
29
+ color: ${ props => props.theme.font.normal };
30
+ white-space: nowrap;
31
+
32
+ &:hover {
33
+ text-decoration: none;
34
+ background: ${ props => props.theme.background.neutral };
35
+ }
36
+ `;
37
+
38
+ /**
39
+ * Anchored sections need somewhere to land that is not under the sticky bar
40
+ * above them.
41
+ */
42
+ export const Anchored = styled.div`
43
+ scroll-margin-top: 60px;
44
+ `;
package/Step1/prepare.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Params } from 'react-router-dom';
2
- import { futureDate } from '@autobusal/utilities';
2
+ import { futureDate, isPreparedDate } from '@autobusal/utilities';
3
3
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
4
 
5
5
  const prepare = (
@@ -18,7 +18,26 @@ const prepare = (
18
18
  const to = params.to ?? defaultLocations.to;
19
19
 
20
20
  const departure = params.departure?.replace(/-/g, '/') ?? futureDate(1);
21
- const _return = params._return?.replace(/-/g, '/') ?? futureDate(2);
21
+
22
+ /*
23
+ * Edited: Claude - Date: 2026-08-29
24
+ *
25
+ * THE RETURN DATE IS TIED TO THE OUTBOUND. A one-way URL carries the
26
+ * literal "none" in the return slot, and `??` only catches null - so
27
+ * "none" travelled all the way into the picker, whose grid is built by
28
+ * splitting on "/" and feeding Date.UTC: an Invalid Date, rendered as
29
+ * January. Switching a collapsed search from One Way to Round Trip
30
+ * showed exactly that.
31
+ *
32
+ * Anything that is not a real date falls back to the departure, which is
33
+ * also the picker's floor - so the return calendar opens on the month
34
+ * being travelled and cannot offer a day before the outbound.
35
+ */
36
+ const requested = params._return?.replace(/-/g, '/');
37
+
38
+ const _return = isPreparedDate(requested)
39
+ ? String(requested)
40
+ : (isPreparedDate(departure) ? departure : futureDate(2));
22
41
 
23
42
  const adults = params.adults ?? 1;
24
43
  const children = params.children ?? 0;
package/Step2.tsx CHANGED
@@ -8,6 +8,7 @@ import { Title, Actions } from './styles';
8
8
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
9
9
  import { FoundData } from '@autobusal/providers/types/routes';
10
10
  import { useGetDepartures } from './services';
11
+ import { useGetSuggestions } from '@autobusal/routes-search/services';
11
12
 
12
13
  interface Props {
13
14
  step1: RoutesSearchForm
@@ -20,11 +21,43 @@ interface Props {
20
21
  const Step2 = ({ step1, t, onSearch, onSave, onBack }: Props): JSX.Element => {
21
22
  const { data, isFetching } = useGetDepartures(step1);
22
23
 
24
+ /**
25
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
26
+ *
27
+ * "Select a Departure Schedule" said nothing about the trip and nothing
28
+ * a search engine could use. The heading now names the actual journey.
29
+ *
30
+ * Countries are dropped when both ends share one: "from Tirana Albania
31
+ * to Durres Albania" is noise, while on a cross-border trip the country
32
+ * is the part that matters.
33
+ *
34
+ * Read from the city list rather than from the results, so the heading
35
+ * is right even when the search finds nothing - which is exactly when
36
+ * being told what was searched for matters most. That list is the same
37
+ * cached query the search form's autocomplete already uses, so it costs
38
+ * no extra request.
39
+ */
40
+ const { data: cities } = useGetSuggestions();
41
+
42
+ const from = cities.find(city => city.slug === step1.from);
43
+ const to = cities.find(city => city.slug === step1.to);
44
+
45
+ const sameCountry = from?.country?.id !== undefined && from.country.id === to?.country?.id;
46
+
47
+ const title = from && to
48
+ ? t('routes_order.step2.title_pair', {
49
+ from: from.name,
50
+ to: to.name,
51
+ from_country: sameCountry ? '' : ` ${ from.country?.name ?? '' }`,
52
+ to_country: sameCountry ? '' : ` ${ to.country?.name ?? '' }`
53
+ })
54
+ : t('routes_order.step2.title');
55
+
23
56
  return (
24
57
  <>
25
58
  <Title>
26
59
  <RiBus2Line />
27
- { t('routes_order.step2.title') }
60
+ { title }
28
61
  </Title>
29
62
 
30
63
  <Steps value={ 2 } />