@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
package/RoutesOrder.tsx
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import { useParams } from 'react-router-dom';
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { useParams, useSearchParams } from 'react-router-dom';
|
|
3
3
|
import { TFunction } from 'i18next';
|
|
4
|
+
import { AiOutlineSearch, AiOutlineCaretDown, AiOutlineCaretUp } from 'react-icons/ai';
|
|
4
5
|
import { Meta } from '@autobusal/common';
|
|
6
|
+
import { useSystemTheme } from '@autobusal/hooks';
|
|
7
|
+
import { useGetSettings } from '@autobusal/providers/services';
|
|
8
|
+
import { selectItem } from '@autobusal/providers/Setup/commerce';
|
|
9
|
+
import { item as commerceItem } from './commerce';
|
|
10
|
+
import { currencyOf } from './Found/refine';
|
|
11
|
+
import RoutesSearch from '@autobusal/routes-search';
|
|
5
12
|
import { useGetSuggestions } from '@autobusal/routes-search/services';
|
|
13
|
+
import { SearchAgain, SearchInner, SearchToggle, SearchSummary, SearchPanel } from './styles';
|
|
14
|
+
import Sections from './Sections/Sections';
|
|
6
15
|
import Step1 from './Step1/Step1';
|
|
16
|
+
import prepare from './Step1/prepare';
|
|
7
17
|
import Step2 from './Step2';
|
|
8
18
|
import Step3 from './Step3';
|
|
9
|
-
import
|
|
10
|
-
import Step5 from './Step5/Step5';
|
|
19
|
+
import Checkout from './Checkout/Checkout';
|
|
11
20
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
12
21
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
13
22
|
import { PersonData } from '@autobusal/providers/types/persons';
|
|
@@ -30,6 +39,12 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
30
39
|
// search form (no :from/:to params yet) or if a slug doesn't resolve.
|
|
31
40
|
const { data: cities } = useGetSuggestions();
|
|
32
41
|
|
|
42
|
+
const { data: settings } = useGetSettings();
|
|
43
|
+
|
|
44
|
+
// same artwork the homepage hero uses, so the results page reads as the
|
|
45
|
+
// same site rather than a bare form on a white band
|
|
46
|
+
const mode = useSystemTheme();
|
|
47
|
+
|
|
33
48
|
const fromCity = cities.find(city => city.slug === params.from);
|
|
34
49
|
const toCity = cities.find(city => city.slug === params.to);
|
|
35
50
|
|
|
@@ -41,13 +56,123 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
41
56
|
? t('routes_order.meta.description_pair', { from: fromCity.name, to: toCity.name })
|
|
42
57
|
: t('routes_order.meta.description');
|
|
43
58
|
|
|
59
|
+
const [ searchOpen, setSearchOpen ] = useState<boolean>(false);
|
|
44
60
|
const [ redirected, setRedirected ] = useState<boolean>(false);
|
|
45
|
-
const [ current, setCurrent ] = useState<number>(1);
|
|
46
61
|
const [ step1, setStep1 ] = useState<RoutesSearchForm | undefined>(undefined);
|
|
47
62
|
const [ step2, setStep2 ] = useState<FoundData | undefined>(undefined);
|
|
48
63
|
const [ step3, setStep3 ] = useState<FoundData | undefined>(undefined);
|
|
49
64
|
const [ step4, setStep4 ] = useState<PersonData | undefined>(undefined);
|
|
50
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
68
|
+
*
|
|
69
|
+
* THE STEP LIVES IN THE URL, and `current` is derived from it - not the
|
|
70
|
+
* other way round. The wizard held the step in component state and never
|
|
71
|
+
* touched the address bar, so the URL still said "search" while the buyer
|
|
72
|
+
* was on the checkout screen. Nothing in the flow was shareable, a refresh
|
|
73
|
+
* dropped you back to the search, and Back did not step back through the
|
|
74
|
+
* wizard - it left it entirely, because no step had ever created a history
|
|
75
|
+
* entry.
|
|
76
|
+
*
|
|
77
|
+
* A SEARCH PARAM rather than a path segment, deliberately. The pair page
|
|
78
|
+
* /bus-lines/:from/:to is a crawlable page in the sitemap, and hanging
|
|
79
|
+
* child routes off it would put near-identical URLs in front of a crawler
|
|
80
|
+
* for what is UI state rather than content. The search spec itself is
|
|
81
|
+
* already in the path on the 9-segment route, so between the two the whole
|
|
82
|
+
* position is in the URL.
|
|
83
|
+
*
|
|
84
|
+
* THE GUARDS ARE THE POINT. A step is only honoured when the state it
|
|
85
|
+
* needs actually exists - arriving at ?step=checkout in a fresh tab has no
|
|
86
|
+
* chosen route to check out, so it falls back to the results rather than
|
|
87
|
+
* rendering half a screen. That is why this reads as a chain of conditions
|
|
88
|
+
* and not a lookup.
|
|
89
|
+
*/
|
|
90
|
+
const [ searchParams, setSearchParams ] = useSearchParams();
|
|
91
|
+
|
|
92
|
+
const step = searchParams.get('step');
|
|
93
|
+
|
|
94
|
+
const current = (() => {
|
|
95
|
+
if (step === 'checkout' && step1 !== undefined && step2 !== undefined) {
|
|
96
|
+
return 4;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (step === 'return' && step1 !== undefined && step2 !== undefined) {
|
|
100
|
+
return 3;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A search has run, so the results are the honest screen to be on -
|
|
104
|
+
// whether the step param says so, names something we cannot honour, or
|
|
105
|
+
// is absent because the URL carried a complete search spec of its own.
|
|
106
|
+
return step1 !== undefined ? 2 : 1;
|
|
107
|
+
})();
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Move to a step, leaving a history entry so Back returns here.
|
|
111
|
+
*
|
|
112
|
+
* `replace` for the screen a search lands on: a search is not somewhere a
|
|
113
|
+
* buyer chose to go back to mid-flow, it is where they already are.
|
|
114
|
+
*/
|
|
115
|
+
const goto = (to: (string | null), replace = false): void => {
|
|
116
|
+
setSearchParams(previous => {
|
|
117
|
+
const next = new URLSearchParams(previous);
|
|
118
|
+
|
|
119
|
+
if (to === null) {
|
|
120
|
+
next.delete('step');
|
|
121
|
+
} else {
|
|
122
|
+
next.set('step', to);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return next;
|
|
126
|
+
}, { replace });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Follow the URL when it describes a different search.
|
|
131
|
+
*
|
|
132
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
133
|
+
*
|
|
134
|
+
* THE SEARCH LIVES ON STEP 1, WHICH IS NOT MOUNTED once results are on
|
|
135
|
+
* screen - `showStep1` is false from step 2 onwards. So a link that
|
|
136
|
+
* changed the address bar was read by nobody: the URL said one journey
|
|
137
|
+
* and the results underneath still showed another. That is why the
|
|
138
|
+
* empty-search suggestions carried `reloadDocument`, throwing away the
|
|
139
|
+
* whole application to do what a route change should have done by itself.
|
|
140
|
+
*
|
|
141
|
+
* Keyed on the SPEC the URL describes rather than on a "have we searched
|
|
142
|
+
* yet" flag. A flag can only ever answer once; a key re-answers whenever
|
|
143
|
+
* the question changes and stays quiet while it does not, which is the
|
|
144
|
+
* behaviour that was actually wanted. It also cannot loop: writing state
|
|
145
|
+
* does not alter the URL the key is derived from.
|
|
146
|
+
*
|
|
147
|
+
* The FIRST search is still Step1's - it owns the empty state a visitor
|
|
148
|
+
* arrives at, and duplicating that here would run it twice.
|
|
149
|
+
*/
|
|
150
|
+
const urlSpec = useMemo(
|
|
151
|
+
() => prepare(undefined, params, searchParams, settings.preferences.defaultLocations),
|
|
152
|
+
[params, searchParams, settings.preferences.defaultLocations]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Only a URL that actually carries a complete search - the bare
|
|
156
|
+
// /bus-lines form describes nothing to follow.
|
|
157
|
+
const urlKey = params.departure !== undefined ? JSON.stringify(urlSpec) : null;
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (urlKey === null || step1 === undefined || urlKey === JSON.stringify(step1)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
setStep1(urlSpec);
|
|
165
|
+
|
|
166
|
+
// the previous journey's chosen coach is not on offer for a different
|
|
167
|
+
// search, and leaving it set would let the guards resolve to a checkout
|
|
168
|
+
// for a route the buyer never picked
|
|
169
|
+
setStep2(undefined);
|
|
170
|
+
setStep3(undefined);
|
|
171
|
+
|
|
172
|
+
goto(null, true);
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
}, [urlKey]);
|
|
175
|
+
|
|
51
176
|
const onSearchDate = (type: ('departure' | '_return'), day: string): void => {
|
|
52
177
|
if (step1 !== undefined) {
|
|
53
178
|
setStep1({
|
|
@@ -59,7 +184,7 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
59
184
|
|
|
60
185
|
const onStep1 = (data: RoutesSearchForm): void => {
|
|
61
186
|
setStep1(data);
|
|
62
|
-
|
|
187
|
+
goto(null, true);
|
|
63
188
|
|
|
64
189
|
// we mark it as redirected
|
|
65
190
|
setRedirected(true);
|
|
@@ -68,35 +193,93 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
68
193
|
const onStep2 = (data: FoundData): void => {
|
|
69
194
|
setStep2(data);
|
|
70
195
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
setCurrent(4);
|
|
75
|
-
}
|
|
196
|
+
selectItem(commerceItem(data), currencyOf([data]));
|
|
197
|
+
|
|
198
|
+
goto(step1?.type === 'return' ? 'return' : 'checkout');
|
|
76
199
|
};
|
|
77
200
|
|
|
78
201
|
const onStep3 = (data: FoundData): void => {
|
|
79
202
|
setStep3(data);
|
|
80
|
-
setCurrent(4);
|
|
81
|
-
};
|
|
82
203
|
|
|
83
|
-
|
|
84
|
-
setStep4(data);
|
|
85
|
-
setCurrent(5);
|
|
204
|
+
goto('checkout');
|
|
86
205
|
};
|
|
87
206
|
|
|
88
|
-
|
|
89
|
-
|
|
207
|
+
/**
|
|
208
|
+
* The in-page Back buttons, moving through the URL like the browser's own
|
|
209
|
+
* - so the two can never disagree about where the buyer is.
|
|
210
|
+
*
|
|
211
|
+
* The return step is only reachable on a booking that HAS a return leg.
|
|
212
|
+
* Guarded here as well as at the call site, on the same principle as
|
|
213
|
+
* `current` above: a step nobody can honour should never reach the URL,
|
|
214
|
+
* whoever asked for it.
|
|
215
|
+
*/
|
|
216
|
+
const onBack = (to: number): void => {
|
|
217
|
+
if (to === 3 && step1?.type === 'return') {
|
|
218
|
+
return goto('return');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
goto(to === 4 ? 'checkout' : null);
|
|
90
222
|
};
|
|
91
223
|
|
|
92
224
|
const showStep1 = current === 1;
|
|
93
225
|
const showStep2 = current === 2 && step1 !== undefined;
|
|
94
226
|
const showStep3 = current === 3 && step1 !== undefined && step2 !== undefined;
|
|
95
|
-
|
|
96
|
-
|
|
227
|
+
|
|
228
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
229
|
+
// Step 4 is now the whole of the rest: passenger details and payment on
|
|
230
|
+
// one screen, so there is no step 5 and no intermediate handoff of
|
|
231
|
+
// passenger data between them.
|
|
232
|
+
const showCheckout = current === 4 && step1 !== undefined && step2 !== undefined;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
236
|
+
*
|
|
237
|
+
* The search form stays on screen while the user is choosing a route.
|
|
238
|
+
* Adjusting a date or a city used to mean "Back to Search", which threw
|
|
239
|
+
* away the results and any filters with them.
|
|
240
|
+
*
|
|
241
|
+
* Only on the route-selection screens: past that point a passenger has
|
|
242
|
+
* committed to a specific trip, and quietly re-running the search under
|
|
243
|
+
* them would discard passenger details they had already typed.
|
|
244
|
+
*
|
|
245
|
+
* onStep1 is the same handler Step1 uses, so submitting here re-runs the
|
|
246
|
+
* search and lands back on step 2 exactly as a fresh search would.
|
|
247
|
+
*/
|
|
248
|
+
const showSearchAgain = (current === 2 || current === 3) && step1 !== undefined;
|
|
97
249
|
|
|
98
250
|
return (
|
|
99
251
|
<Meta title={ title } description={ description }>
|
|
252
|
+
{ showSearchAgain && step1 !== undefined && (
|
|
253
|
+
<SearchAgain $background={ `${ settings.url }/home/background-${ mode }.jpg` }>
|
|
254
|
+
<SearchInner>
|
|
255
|
+
<SearchToggle type="button" aria-expanded={ searchOpen } onClick={ () => setSearchOpen(!searchOpen) }>
|
|
256
|
+
<AiOutlineSearch />
|
|
257
|
+
|
|
258
|
+
<SearchSummary>
|
|
259
|
+
{ fromCity?.name ?? step1.from } → { toCity?.name ?? step1.to } · { step1.departure }
|
|
260
|
+
</SearchSummary>
|
|
261
|
+
|
|
262
|
+
{ searchOpen ? <AiOutlineCaretUp /> : <AiOutlineCaretDown /> }
|
|
263
|
+
</SearchToggle>
|
|
264
|
+
|
|
265
|
+
<SearchPanel $open={ searchOpen }>
|
|
266
|
+
<RoutesSearch
|
|
267
|
+
type={ step1.type ?? 'departure' }
|
|
268
|
+
from={ `${ step1.from }${ step1.stop_from ? `:${ step1.stop_from }` : '' }` }
|
|
269
|
+
to={ `${ step1.to }${ step1.stop_to ? `:${ step1.stop_to }` : '' }` }
|
|
270
|
+
departure={ step1.departure }
|
|
271
|
+
_return={ step1._return ?? '' }
|
|
272
|
+
adultsNo={ step1.adults }
|
|
273
|
+
childrenNo={ step1.children }
|
|
274
|
+
babiesNo={ step1.babies }
|
|
275
|
+
t={ t }
|
|
276
|
+
onSearch={ data => { setSearchOpen(false); onStep1(data); } }
|
|
277
|
+
/>
|
|
278
|
+
</SearchPanel>
|
|
279
|
+
</SearchInner>
|
|
280
|
+
</SearchAgain>
|
|
281
|
+
) }
|
|
282
|
+
|
|
100
283
|
{ showStep1 && (
|
|
101
284
|
<Step1
|
|
102
285
|
value={ step1 }
|
|
@@ -106,6 +289,13 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
106
289
|
/>
|
|
107
290
|
) }
|
|
108
291
|
|
|
292
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
293
|
+
The anchor the section nav's "Trips" link lands on. A plain
|
|
294
|
+
wrapper: the wizard inside is untouched, which is the whole point
|
|
295
|
+
of Tier 3.1 - the funnel keeps working exactly as it did and the
|
|
296
|
+
new sections sit around it. */ }
|
|
297
|
+
<div id="trips">
|
|
298
|
+
|
|
109
299
|
{ showStep2 && (
|
|
110
300
|
<Step2
|
|
111
301
|
step1={ step1 }
|
|
@@ -127,25 +317,43 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
127
317
|
/>
|
|
128
318
|
) }
|
|
129
319
|
|
|
130
|
-
{
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
320
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-03, widened 2026-08-11
|
|
321
|
+
Roadmap Tier 3.2. NOT gated on step2/step3 (as it was originally)
|
|
322
|
+
- it now also renders on showStep1, which is what actually matters:
|
|
323
|
+
/bus-lines/:from/:to is the URL the sitemap lists and the
|
|
324
|
+
prerender crawl snapshots, and that URL is ALWAYS on step 1 - a
|
|
325
|
+
date has not been picked yet, so step2/step3 never mount and the
|
|
326
|
+
block never rendered for a single crawler. A logged human visitor
|
|
327
|
+
benefits the same way a search engine does: price/duration/
|
|
328
|
+
schedule for the pair, visible before they commit to a date.
|
|
329
|
+
|
|
330
|
+
Bare /bus-lines (no :from/:to) leaves both props undefined,
|
|
331
|
+
useGetFacts short-circuits on that, and Sections' own guard
|
|
332
|
+
returns null with nothing fetched - so the generic search form
|
|
333
|
+
gains no empty box underneath it.
|
|
334
|
+
|
|
335
|
+
Past step 2 the reader has picked a coach and is filling in
|
|
336
|
+
passenger details - facts about the pair are noise at that point,
|
|
337
|
+
which is why it still stops at step 3 and does not follow onto
|
|
338
|
+
checkout. */ }
|
|
339
|
+
{ (showStep1 || showStep2 || showStep3) && (
|
|
340
|
+
<Sections
|
|
341
|
+
from={ fromCity?.slug ?? params.from }
|
|
342
|
+
to={ toCity?.slug ?? params.to }
|
|
136
343
|
t={ t }
|
|
137
|
-
onSave={ onStep4 }
|
|
138
|
-
onBack={ onBack }
|
|
139
344
|
/>
|
|
140
345
|
) }
|
|
141
346
|
|
|
142
|
-
|
|
143
|
-
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
{ showCheckout && (
|
|
350
|
+
<Checkout
|
|
351
|
+
values={ step4 }
|
|
144
352
|
step1={ step1 }
|
|
145
353
|
step2={ step2 }
|
|
146
354
|
step3={ step3 }
|
|
147
|
-
step4={ step4 }
|
|
148
355
|
t={ t }
|
|
356
|
+
onStore={ setStep4 }
|
|
149
357
|
onBack={ onBack }
|
|
150
358
|
/>
|
|
151
359
|
) }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Rating } from '@autobusal/common';
|
|
3
|
+
import { Container, Table, Time, Faded, Price, Operator, NextDay } from './styles';
|
|
4
|
+
import { ScheduleRow } from '../Facts/types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
id: string
|
|
8
|
+
from: string
|
|
9
|
+
to: string
|
|
10
|
+
rows: ScheduleRow[]
|
|
11
|
+
t: TFunction<'common'>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every service on this city pair, as a table.
|
|
16
|
+
*
|
|
17
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
18
|
+
*
|
|
19
|
+
* Roadmap Tier 3.3, and the part of this tier that actually ranks. A page
|
|
20
|
+
* about "Tirana to Durres bus" competes on being dense, unique to the pair,
|
|
21
|
+
* and made of the words somebody types - and a timetable is all three at
|
|
22
|
+
* once, without a single sentence of written copy.
|
|
23
|
+
*
|
|
24
|
+
* A real <table>, not a grid of divs, because this genuinely is tabular data:
|
|
25
|
+
* the same structure that makes it readable is what lets a crawler
|
|
26
|
+
* understand the columns. No interactivity at all, so a prerendered snapshot
|
|
27
|
+
* shows a non-JS crawler exactly what a reader sees.
|
|
28
|
+
*
|
|
29
|
+
* Sorted by departure, server-side. Price order would scatter the day and
|
|
30
|
+
* make the table useless for the thing people actually do with a timetable,
|
|
31
|
+
* which is plan around a time.
|
|
32
|
+
*/
|
|
33
|
+
const Schedule = ({ id, from, to, rows, t }: Props): (JSX.Element | null) => {
|
|
34
|
+
if (rows.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const duration = (minutes: number | null): string => {
|
|
39
|
+
if (minutes === null) {
|
|
40
|
+
return '-';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hours = Math.floor(minutes / 60);
|
|
44
|
+
const rest = minutes % 60;
|
|
45
|
+
|
|
46
|
+
// "8h 0m" is a number pretending to be a measurement
|
|
47
|
+
return rest === 0
|
|
48
|
+
? t('routes_order.facts.duration_hours', { hours })
|
|
49
|
+
: t('routes_order.facts.duration_value', { hours, minutes: rest });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Container id={ id } className="box">
|
|
54
|
+
<h2>{ t('routes_order.schedule.title', { from, to }) }</h2>
|
|
55
|
+
|
|
56
|
+
<Table>
|
|
57
|
+
<thead>
|
|
58
|
+
<tr>
|
|
59
|
+
<th>{ t('routes_order.schedule.departure') }</th>
|
|
60
|
+
<th>{ t('routes_order.schedule.arrival') }</th>
|
|
61
|
+
<th>{ t('routes_order.schedule.duration') }</th>
|
|
62
|
+
<th>{ t('routes_order.schedule.stops') }</th>
|
|
63
|
+
<th>{ t('routes_order.schedule.operator') }</th>
|
|
64
|
+
<th>{ t('routes_order.schedule.price') }</th>
|
|
65
|
+
</tr>
|
|
66
|
+
</thead>
|
|
67
|
+
|
|
68
|
+
<tbody>
|
|
69
|
+
{ rows.map((row, index) => (
|
|
70
|
+
<tr key={ index }>
|
|
71
|
+
<td><Time>{ row.departure ?? '-' }</Time></td>
|
|
72
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
73
|
+
The same +1 the result cards carry. A timetable is read
|
|
74
|
+
precisely to plan around a time, so a row whose arrival
|
|
75
|
+
is the following morning and does not say so is the one
|
|
76
|
+
row on the page somebody will actually act on wrongly. */ }
|
|
77
|
+
<td>
|
|
78
|
+
<Time>{ row.arrival ?? '-' }</Time>
|
|
79
|
+
|
|
80
|
+
{ (row.offset ?? 0) > 0 && (
|
|
81
|
+
<NextDay title={ t('routes_order.step2.route.next_day', { count: row.offset }) }>
|
|
82
|
+
+{ row.offset }
|
|
83
|
+
</NextDay>
|
|
84
|
+
) }
|
|
85
|
+
</td>
|
|
86
|
+
<td><Faded>{ duration(row.minutes) }</Faded></td>
|
|
87
|
+
|
|
88
|
+
<td>
|
|
89
|
+
{ /* "Direct" is the word people scan for; "0 stops" makes
|
|
90
|
+
the reader do that translation themselves.
|
|
91
|
+
|
|
92
|
+
Above zero it is the bare NUMBER, not "{{count}} stops":
|
|
93
|
+
the column header already says Stops, and an interpolated
|
|
94
|
+
noun rendered "1 stops" - this codebase has no i18next
|
|
95
|
+
plural forms anywhere, and adding them for one cell would
|
|
96
|
+
mean getting three-form Slavic plurals right in fifteen
|
|
97
|
+
languages to say something the header already said. */ }
|
|
98
|
+
<Faded>
|
|
99
|
+
{ row.stops === 0 ? t('routes_order.schedule.direct') : row.stops }
|
|
100
|
+
</Faded>
|
|
101
|
+
</td>
|
|
102
|
+
|
|
103
|
+
<td>
|
|
104
|
+
<Operator>
|
|
105
|
+
{ row.operator ?? '-' }
|
|
106
|
+
|
|
107
|
+
{ /* Renders nothing until the operator has enough
|
|
108
|
+
published reviews - see Tier 2. A new operator must
|
|
109
|
+
not read as a badly rated one. */ }
|
|
110
|
+
<Rating rating={ row.rating } size="small" t={ t } />
|
|
111
|
+
</Operator>
|
|
112
|
+
</td>
|
|
113
|
+
|
|
114
|
+
<td><Price>{ row.price }</Price></td>
|
|
115
|
+
</tr>
|
|
116
|
+
)) }
|
|
117
|
+
</tbody>
|
|
118
|
+
</Table>
|
|
119
|
+
</Container>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export default Schedule;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.section`
|
|
4
|
+
margin-top: 25px;
|
|
5
|
+
overflow-x: auto;
|
|
6
|
+
`;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A real table, because this IS tabular data - the same reason a crawler
|
|
10
|
+
* understands it. Scrolls inside its own container on a narrow screen rather
|
|
11
|
+
* than pushing the page sideways.
|
|
12
|
+
*/
|
|
13
|
+
export const Table = styled.table`
|
|
14
|
+
width: 100%;
|
|
15
|
+
border-collapse: collapse;
|
|
16
|
+
min-width: 620px;
|
|
17
|
+
|
|
18
|
+
th, td {
|
|
19
|
+
padding: 10px 12px;
|
|
20
|
+
text-align: left;
|
|
21
|
+
border-bottom: 1px solid ${ props => props.theme.background.neutral };
|
|
22
|
+
white-space: nowrap;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
th {
|
|
26
|
+
font-size: ${ props => props.theme.size.s };
|
|
27
|
+
color: ${ props => props.theme.font.faded };
|
|
28
|
+
font-weight: 400;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
tbody tr:last-child td {
|
|
32
|
+
border-bottom: none;
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
export const Time = styled.strong`
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
export const Faded = styled.span`
|
|
41
|
+
color: ${ props => props.theme.font.faded };
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export const Price = styled.strong`
|
|
45
|
+
font-weight: 700;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
export const Operator = styled.div`
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
gap: 2px;
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The +1 on an overnight arrival, matching the result cards.
|
|
57
|
+
*
|
|
58
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
59
|
+
*/
|
|
60
|
+
export const NextDay = styled.sup`
|
|
61
|
+
margin-left: 4px;
|
|
62
|
+
padding: 1px 4px;
|
|
63
|
+
border-radius: 4px;
|
|
64
|
+
font-size: ${ props => props.theme.size.xs };
|
|
65
|
+
font-weight: 700;
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
vertical-align: super;
|
|
68
|
+
cursor: help;
|
|
69
|
+
color: ${ props => props.theme.font.normal };
|
|
70
|
+
background: ${ props => props.theme.primary.neutral };
|
|
71
|
+
`;
|