@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
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 Step4 from './Step4/Step4';
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
- setCurrent(2);
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
- if (step1?.type === 'return') {
72
- setCurrent(3);
73
- } else {
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
- const onStep4 = (data: PersonData): void => {
84
- setStep4(data);
85
- setCurrent(5);
204
+ goto('checkout');
86
205
  };
87
206
 
88
- const onBack = (step: number): void => {
89
- setCurrent(step);
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
- const showStep4 = current === 4 && step1 !== undefined && step2 !== undefined;
96
- const showStep5 = current === 5 && step1 !== undefined && step2 !== undefined && step4 !== undefined;
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
- { showStep4 && (
131
- <Step4
132
- values={ step4 }
133
- step1={ step1 }
134
- step2={ step2 }
135
- step3={ step3 }
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
- { showStep5 && (
143
- <Step5
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
+ `;