@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.
- package/CHANGELOG.md +673 -0
- package/Checkout/Checkout.tsx +519 -0
- package/Checkout/Sidebar/Sidebar.tsx +124 -0
- package/Checkout/Sidebar/styles.ts +52 -0
- package/Facts/Facts.tsx +271 -0
- package/Facts/questions.ts +250 -0
- package/Facts/service.ts +111 -0
- package/Facts/services.ts +19 -0
- package/Facts/styles.ts +112 -0
- package/Facts/types.ts +167 -0
- package/Faq/Faq.tsx +52 -0
- package/Faq/styles.ts +68 -0
- package/Found/Dates/Dates.tsx +59 -5
- package/Found/Dates/styles.ts +137 -7
- package/Found/Empty/Empty.tsx +136 -0
- package/Found/Empty/styles.ts +98 -0
- package/Found/Filters/Filters.tsx +10 -7
- package/Found/Filters/styles.ts +79 -8
- package/Found/Found.tsx +128 -29
- package/Found/Group/Group.tsx +3 -1
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +25 -4
- package/Found/Picks/Picks.tsx +10 -3
- package/Found/Picks/styles.ts +60 -9
- package/Found/Route/Route.tsx +189 -33
- package/Found/Route/styles.ts +488 -50
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/styles.ts +52 -7
- package/Found/Summary/Summary.tsx +1 -1
- package/Found/Summary/styles.ts +12 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/Found/refine.ts +69 -0
- package/Links/Links.tsx +91 -0
- package/Links/styles.ts +75 -0
- package/RoutesOrder.tsx +239 -31
- package/Schedule/Schedule.tsx +123 -0
- package/Schedule/styles.ts +71 -0
- package/Sections/Schema.tsx +186 -0
- package/Sections/Sections.tsx +160 -0
- package/Sections/styles.ts +44 -0
- package/Step1/prepare.ts +21 -2
- package/Step2.tsx +34 -1
- package/Step4/Passenger/Passenger.tsx +253 -24
- package/Step4/Passenger/styles.ts +21 -1
- package/Step4/Passengers/Display.tsx +10 -2
- package/Step4/Passengers/Passengers.tsx +53 -4
- package/Step4/Saved/Choose.tsx +99 -0
- package/Step4/Saved/Remember.tsx +58 -0
- package/Step4/Saved/styles.ts +87 -0
- package/Step4/checkAge.ts +15 -4
- package/Step5/Addons/Addons.tsx +146 -4
- package/Step5/Addons/styles.ts +94 -0
- package/Step5/Billing/Billing.tsx +138 -70
- package/Step5/Coupons/Coupons.tsx +28 -18
- package/Step5/Coupons/styles.ts +41 -8
- package/Step5/Payments/Payments.tsx +36 -5
- package/Step5/Payments/styles.ts +16 -7
- package/Step5/Summary/About/About.tsx +99 -27
- package/Step5/Summary/About/styles.ts +118 -1
- package/Step5/Summary/styles.ts +12 -5
- package/Step5/utilities.ts +67 -12
- package/Steps/Steps.tsx +3 -1
- package/commerce.ts +24 -0
- package/package.json +1 -1
- package/services.ts +295 -12
- package/styles.ts +126 -0
- package/types.ts +78 -1
- package/Found/Booking/Booking.tsx +0 -61
- package/Found/Booking/styles.ts +0 -50
- package/Step4/Step4.tsx +0 -114
- 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
|
-
|
|
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
|
-
{
|
|
60
|
+
{ title }
|
|
28
61
|
</Title>
|
|
29
62
|
|
|
30
63
|
<Steps value={ 2 } />
|