@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
@@ -1,10 +1,24 @@
1
1
  import styled, { css } from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
5
+ *
6
+ * A column on a phone, so the five options get the whole width for one row
7
+ * instead of losing a third of it to the "Sort by" label and wrapping onto
8
+ * three lines.
9
+ */
3
10
  export const Container = styled.div`
4
11
  display: flex;
5
- align-items: center;
6
- gap: 8px;
7
- flex-wrap: wrap;
12
+ flex-direction: column;
13
+ align-items: stretch;
14
+ gap: 6px;
15
+
16
+ @media (min-width: 640px) {
17
+ flex-direction: row;
18
+ align-items: center;
19
+ gap: 8px;
20
+ flex-wrap: wrap;
21
+ }
8
22
  `;
9
23
 
10
24
  export const Label = styled.span`
@@ -13,17 +27,48 @@ export const Label = styled.span`
13
27
  color: ${ props => props.theme.font.faded };
14
28
  `;
15
29
 
30
+ /**
31
+ * One row, always. Five short options wrapping onto three lines took more
32
+ * height than the first result they were meant to reorder.
33
+ */
16
34
  export const Options = styled.div`
17
35
  display: flex;
18
- gap: 6px;
19
- flex-wrap: wrap;
36
+ gap: 3px;
37
+ flex-wrap: nowrap;
38
+
39
+ @media (min-width: 640px) {
40
+ gap: 6px;
41
+ flex-wrap: wrap;
42
+ }
20
43
  `;
21
44
 
22
45
  export const Option = styled.button<{ $active: boolean }>`
23
- padding: 6px 14px;
24
- font-size: ${ props => props.theme.size.s };
46
+ /**
47
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
48
+ *
49
+ * NATURAL width, allowed to shrink - not an equal share. Equal shares gave
50
+ * every option 59px, which four of the five did not need and the fifth
51
+ * did: "Recommended" wants 71px and was the only one ever truncated, to
52
+ * "Recom...", while its neighbours sat in whitespace. Left to size
53
+ * themselves the five come to 311px inside a 335px row and all fit.
54
+ *
55
+ * They can still shrink, so a language with longer words degrades to
56
+ * ellipsis rather than pushing an option off the edge.
57
+ */
58
+ flex: 0 1 auto;
59
+ min-width: 0;
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ padding: 5px 2px;
63
+ font-size: calc(${ props => props.theme.size.xs } - 2px);
25
64
  font-weight: 700;
26
65
  white-space: nowrap;
66
+
67
+ @media (min-width: 640px) {
68
+ flex: initial;
69
+ padding: 6px 14px;
70
+ font-size: ${ props => props.theme.size.s };
71
+ }
27
72
  border-radius: 100px;
28
73
  border: 1px solid ${ props => props.theme.inputs.border };
29
74
  background: ${ props => props.theme.background.normal };
@@ -20,7 +20,7 @@ interface Props {
20
20
  * results, which is otherwise easy to forget after scrolling.
21
21
  */
22
22
  const Summary = ({ shown, total, range, t, children }: Props): JSX.Element => (
23
- <Container>
23
+ <Container className="box">
24
24
  <Result>
25
25
  { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
26
26
  `total`, not `count` - i18next treats a `count` variable as a
@@ -1,11 +1,23 @@
1
1
  import styled from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
5
+ *
6
+ * Carries the `box` surface (padding is overridden to something tighter -
7
+ * this is a bar, not a panel) so the count and the sort control sit on the
8
+ * same card background as everything else on the page rather than floating
9
+ * loose on the page background.
10
+ */
3
11
  export const Container = styled.div`
4
12
  display: flex;
5
13
  flex-direction: column;
6
14
  gap: 12px;
7
15
  margin-bottom: 15px;
8
16
 
17
+ &.box {
18
+ padding: 12px 15px;
19
+ }
20
+
9
21
  @media (min-width: 1024px) {
10
22
  flex-direction: row;
11
23
  align-items: center;
@@ -0,0 +1,187 @@
1
+ import { useState, useId } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { useForm } from 'react-hook-form';
4
+ import { AiOutlineBell, AiOutlineCheckCircle } from 'react-icons/ai';
5
+ import { Button } from '@autobusal/common';
6
+ import { Validate, Display } from '@autobusal/utilities';
7
+ import { useGetSuggestions } from '@autobusal/routes-search/services';
8
+ import { Container, Toggle, Panel, Lead, Journey, Fields, Field, Action, Privacy, Failure, Done } from './styles';
9
+ import { PriceAlertForm, PriceAlertData } from '../../types';
10
+ import { usePostPriceAlert } from '../../services';
11
+
12
+ interface Props {
13
+ from: string
14
+ to: string
15
+ departure: string
16
+ t: TFunction<'common'>
17
+ }
18
+
19
+ /**
20
+ * "Watch this fare" - tell me by email if the price for this journey drops.
21
+ *
22
+ * Edited: Claude - Date: 2026-08-20
23
+ *
24
+ * WHERE IT SITS is the whole design. This renders at the END of the results,
25
+ * from <Found>, which is the one component on this screen that is mounted in
26
+ * every state it can be in: trips listed, trips all hidden by a filter, and
27
+ * nothing found at all. A control placed inside the listing would have
28
+ * disappeared in exactly the state that most needs it - "no buses for this
29
+ * search" is when somebody most wants telling if that changes - and one
30
+ * placed above the trips would have argued with the results for attention
31
+ * on the page whose entire job is showing them.
32
+ *
33
+ * The pair and the date are the search that has already been run, so the
34
+ * only thing asked for is an address. Collapsed until pressed: this is for
35
+ * the minority who did not find their price, and an open form under every
36
+ * result set is a second thing to read past.
37
+ *
38
+ * THRESHOLD IS DELIBERATELY NOT COLLECTED. The endpoint accepts one, but
39
+ * "tell me if it goes down" needs no number from the traveller, and a
40
+ * second field is how a one-line control becomes a form nobody fills in.
41
+ */
42
+ const Watch = ({ from, to, departure, t }: Props): JSX.Element => {
43
+ const [ open, setOpen ] = useState<boolean>(false);
44
+ const [ done, setDone ] = useState<PriceAlertData | undefined>(undefined);
45
+ const [ failure, setFailure ] = useState<string | undefined>(undefined);
46
+
47
+ /**
48
+ * Edited: Claude - Date: 2026-08-20
49
+ * Same pairing @autobusal/auth and the checkout were just fixed to use: a
50
+ * real <label for> over an id nothing else can own. The results page also
51
+ * carries the footer's newsletter signup, so a bare id="email" would have
52
+ * been the second one on the page.
53
+ */
54
+ const uid = useId();
55
+
56
+ const id = (field: string): string => `${ uid }${ field }`;
57
+
58
+ const { register, handleSubmit, formState: { errors } } = useForm<PriceAlertForm>();
59
+
60
+ const { mutate: WatchFare, isPending } = usePostPriceAlert();
61
+
62
+ // the same cached city list the search form's autocomplete and the step
63
+ // heading already read, so naming the journey costs no extra request
64
+ const { data: cities } = useGetSuggestions();
65
+
66
+ const fromCity = cities.find(city => city.slug === from);
67
+ const toCity = cities.find(city => city.slug === to);
68
+
69
+ const names = {
70
+ from: fromCity?.name ?? from,
71
+ to: toCity?.name ?? to
72
+ };
73
+
74
+ /**
75
+ * Every answer this endpoint can give, turned into something readable.
76
+ *
77
+ * 404 is not an error the visitor made: nobody prices this pair, so there
78
+ * is genuinely no fare to watch, and saying that plainly is the only
79
+ * honest reply. 429 is the write-tier throttle (20/min). 422 also raises
80
+ * the global toast from apiClient's interceptor - it is repeated inline
81
+ * because a toast above the fold is easy to miss from the foot of a long
82
+ * result list, which is exactly where this control lives.
83
+ */
84
+ const message = (status?: number): string => {
85
+ if (status === 404) {
86
+ return t('routes_order.step2.watch.errors.not_found');
87
+ }
88
+
89
+ if (status === 422) {
90
+ return t('routes_order.step2.watch.errors.invalid');
91
+ }
92
+
93
+ if (status === 429) {
94
+ return t('routes_order.step2.watch.errors.throttled');
95
+ }
96
+
97
+ return t('routes_order.step2.watch.errors.failed');
98
+ };
99
+
100
+ const onSubmit = (data: PriceAlertForm): void => {
101
+ setFailure(undefined);
102
+
103
+ WatchFare({
104
+ email: data.email,
105
+ from,
106
+ to,
107
+ departure
108
+ }, {
109
+ onSuccess: (data) => {
110
+ setDone(data);
111
+ },
112
+ onError: (error) => {
113
+ setFailure(message(error.response?.status));
114
+ }
115
+ });
116
+ };
117
+
118
+ if (done !== undefined) {
119
+ return (
120
+ <Container className="box">
121
+ <Done>
122
+ <AiOutlineCheckCircle />
123
+
124
+ <span>
125
+ { t('routes_order.step2.watch.done', {
126
+ price: done.baseline_price_display,
127
+ from: names.from,
128
+ to: names.to
129
+ }) }
130
+ </span>
131
+ </Done>
132
+ </Container>
133
+ );
134
+ }
135
+
136
+ return (
137
+ <Container className="box">
138
+ <Toggle type="button" aria-expanded={ open } onClick={ () => setOpen(!open) }>
139
+ <AiOutlineBell />
140
+
141
+ <span>{ t('routes_order.step2.watch.toggle') }</span>
142
+ </Toggle>
143
+
144
+ { open && (
145
+ <Panel>
146
+ <Lead>{ t('routes_order.step2.watch.lead') }</Lead>
147
+
148
+ <Journey>
149
+ { t('routes_order.step2.watch.journey', {
150
+ from: names.from,
151
+ to: names.to,
152
+ date: departure
153
+ }) }
154
+ </Journey>
155
+
156
+ <form onSubmit={ handleSubmit(onSubmit) }>
157
+ <Fields>
158
+ <Field>
159
+ <label htmlFor={ id('email') }>{ t('routes_order.step2.watch.email') }</label>
160
+
161
+ <input type="email" id={ id('email') } { ...register('email', Validate('required|email|max_length:255', t)) } />
162
+ </Field>
163
+
164
+ <Action>
165
+ <Button
166
+ type="submit"
167
+ size="medium"
168
+ loading={ isPending }
169
+ text={ t('routes_order.step2.watch.submit') }
170
+ noMargin
171
+ />
172
+ </Action>
173
+ </Fields>
174
+
175
+ { Display(errors.email) }
176
+
177
+ { failure !== undefined && <Failure role="alert">{ failure }</Failure> }
178
+
179
+ <Privacy>{ t('routes_order.step2.watch.privacy') }</Privacy>
180
+ </form>
181
+ </Panel>
182
+ ) }
183
+ </Container>
184
+ );
185
+ };
186
+
187
+ export default Watch;
@@ -0,0 +1,118 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * Edited: Claude - Date: 2026-08-20
5
+ *
6
+ * A strip under the results, not a panel over them. This sits at the end of
7
+ * the list - where somebody has read every price and none of them was the
8
+ * one they wanted - so it has to be findable without ever competing with
9
+ * the trips themselves for attention.
10
+ */
11
+ export const Container = styled.div`
12
+ margin-top: 15px;
13
+
14
+ &.box {
15
+ padding: 12px 15px;
16
+ }
17
+ `;
18
+
19
+ export const Toggle = styled.button`
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 8px;
23
+ width: 100%;
24
+ text-align: left;
25
+ font-size: ${ props => props.theme.size.s };
26
+ font-weight: 700;
27
+ color: ${ props => props.theme.font.normal };
28
+
29
+ & > svg {
30
+ flex: 0 0 auto;
31
+ font-size: ${ props => props.theme.size.l };
32
+ color: ${ props => props.theme.primary.normal };
33
+ }
34
+
35
+ &:hover {
36
+ color: ${ props => props.theme.primary.normal };
37
+ }
38
+ `;
39
+
40
+ export const Panel = styled.div`
41
+ margin-top: 12px;
42
+ `;
43
+
44
+ export const Lead = styled.p`
45
+ margin: 0 0 4px;
46
+ font-size: ${ props => props.theme.size.s };
47
+ `;
48
+
49
+ export const Journey = styled.p`
50
+ margin: 0 0 12px;
51
+ font-size: ${ props => props.theme.size.xs };
52
+ color: ${ props => props.theme.font.faded };
53
+ `;
54
+
55
+ /**
56
+ * The field and its action on one line from the tablet up, stacked on a
57
+ * phone - the same shape the coupon box uses, so the two read as siblings.
58
+ */
59
+ export const Fields = styled.div`
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 10px;
63
+
64
+ @media (min-width: 640px) {
65
+ flex-direction: row;
66
+ align-items: flex-end;
67
+ }
68
+ `;
69
+
70
+ export const Field = styled.div`
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 6px;
74
+ flex: 1;
75
+ min-width: 0;
76
+ font-weight: 700;
77
+ font-size: calc(${ props => props.theme.size.m } - 1px);
78
+ `;
79
+
80
+ export const Action = styled.div`
81
+ flex: 0 0 auto;
82
+ `;
83
+
84
+ export const Privacy = styled.p`
85
+ margin: 10px 0 0;
86
+ font-size: ${ props => props.theme.size.xs };
87
+ color: ${ props => props.theme.font.faded };
88
+ `;
89
+
90
+ /**
91
+ * Every answer the server can give, said out loud.
92
+ *
93
+ * Edited: Claude - Date: 2026-08-20
94
+ *
95
+ * The contact form swallowed submissions for months because a failure had
96
+ * nowhere to render. A 404 (nobody prices this pair), a 429 (throttled) and
97
+ * an unreachable API all end up here rather than in the console.
98
+ */
99
+ export const Failure = styled.p`
100
+ margin: 10px 0 0;
101
+ font-size: ${ props => props.theme.size.s };
102
+ color: ${ props => props.theme.font.error };
103
+ `;
104
+
105
+ export const Done = styled.div`
106
+ display: flex;
107
+ gap: 8px;
108
+ align-items: flex-start;
109
+ font-size: ${ props => props.theme.size.s };
110
+ line-height: 1.4;
111
+
112
+ & > svg {
113
+ flex: 0 0 auto;
114
+ margin-top: 2px;
115
+ font-size: ${ props => props.theme.size.l };
116
+ color: ${ props => props.theme.primary.normal };
117
+ }
118
+ `;
package/Found/refine.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { TFunction } from 'i18next';
1
2
  import { FoundData } from '@autobusal/providers/types/routes';
2
3
 
3
4
  /**
@@ -97,6 +98,74 @@ export const durationOf = (item: FoundData): number | null => {
97
98
  return minutesOf(item.duration);
98
99
  };
99
100
 
101
+ /**
102
+ * Turn obtapi's "HH:MM" trip length into something that reads as a
103
+ * duration rather than a clock time.
104
+ *
105
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
106
+ *
107
+ * "08:30" sitting between two departure times is genuinely ambiguous -
108
+ * nothing about it says "eight and a half hours" rather than "half past
109
+ * eight", and on this card it sits directly between 09:00 and 16:30. The
110
+ * unit labels come from the translation files because "h"/"m" are not
111
+ * universal; Greek and the Cyrillic locales carry their own.
112
+ *
113
+ * The "-" sentinel (obtapi could not compute a duration) passes straight
114
+ * through - it must not become "0h 0m", which would read as a real answer.
115
+ */
116
+ export const formatDuration = (duration: string, t: TFunction<'common'>): string => {
117
+ if (!duration || duration === '-') {
118
+ return duration;
119
+ }
120
+
121
+ const total = minutesOf(duration);
122
+
123
+ const hours = Math.floor(total / 60);
124
+ const minutes = total % 60;
125
+
126
+ const pad = (value: number): string => (value <= 9 ? `0${ value }` : String(value));
127
+
128
+ const parts: string[] = [];
129
+
130
+ if (hours > 0) {
131
+ parts.push(t('routes_order.step2.route.duration.hours', { value: pad(hours) }));
132
+ }
133
+
134
+ // a trip under an hour still needs a number, and so does an exact
135
+ // number of hours - but "08h 00m" is noise, so zero minutes are dropped
136
+ if (minutes > 0 || hours === 0) {
137
+ parts.push(t('routes_order.step2.route.duration.minutes', { value: pad(minutes) }));
138
+ }
139
+
140
+ return parts.join(' ');
141
+ };
142
+
143
+ /**
144
+ * Split obtapi's formatted price into its parts so the decimals can be set
145
+ * as superscript.
146
+ *
147
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
148
+ *
149
+ * obtapi sends a rendered string ("1,048.00 EUR" - number_format, so the
150
+ * thousands separator is a comma and the decimal point a dot). Splitting it
151
+ * here rather than reformatting from `price.value` keeps the label's own
152
+ * currency and formatting as the single source of truth; we only decide how
153
+ * big each piece is drawn.
154
+ *
155
+ * Anything that does not match the expected shape is returned whole, so an
156
+ * unusual currency format degrades to today's rendering instead of being
157
+ * mangled.
158
+ */
159
+ export const splitPrice = (display: string): { whole: string, decimals: string | null, currency: string | null } => {
160
+ const match = /^(.+)\.(\d{2})\s+(\S+)$/.exec(display ?? '');
161
+
162
+ if (match === null) {
163
+ return { whole: display ?? '', decimals: null, currency: null };
164
+ }
165
+
166
+ return { whole: match[1], decimals: match[2], currency: match[3] };
167
+ };
168
+
100
169
  export const bucketOf = (item: FoundData): BucketKey => {
101
170
  const hour = Math.floor(departureOf(item) / 60);
102
171
 
@@ -0,0 +1,91 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Container, Group, Heading, List, Item, Reverse, ReverseLink } from './styles';
3
+ import { LinkRow } from '../Facts/types';
4
+
5
+ interface Props {
6
+ id: string
7
+ from: LinkRow[]
8
+ to: LinkRow[]
9
+ // Edited: Claude - Date: 2026-08-20
10
+ // The journey home, or null when it is not sold. Belongs to neither list
11
+ // below - it is a pair from the DESTINATION back to the origin - which is
12
+ // why the one link a reader is most likely to want next was the one this
13
+ // block could not produce.
14
+ reverse: LinkRow | null
15
+ fromName?: string
16
+ toName?: string
17
+ t: TFunction<'common'>
18
+ }
19
+
20
+ /**
21
+ * Where else you can go from here.
22
+ *
23
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
24
+ *
25
+ * Roadmap Tier 3.4. The sitemap announces every sellable pair, but a sitemap
26
+ * is a hint - a page nothing links to is still orphaned, and a crawler that
27
+ * has to be TOLD about a page treats it as less important than one it
28
+ * arrives at by following a link. These blocks put every pair one click from
29
+ * another.
30
+ *
31
+ * Real router links, deliberately: the whole value is that they are followed.
32
+ * Ordered by paid bookings server-side, but never filtered by them - the
33
+ * pairs with no orders yet are exactly the orphans this exists to reach.
34
+ */
35
+ const Links = ({ id, from, to, reverse, fromName, toName, t }: Props): (JSX.Element | null) => {
36
+ if (from.length === 0 && to.length === 0 && !reverse) {
37
+ return null;
38
+ }
39
+
40
+ return (
41
+ <Container id={ id } className="box">
42
+ { /* Edited: Claude - Date: 2026-08-20
43
+ First, and on its own, because pairs are DIRECTIONAL: the return
44
+ leg is a different page with its own timetable and its own fares,
45
+ and it is the likeliest next page for anybody who is planning a
46
+ trip rather than a one-way transfer. Only ever rendered when
47
+ obtapi found the reverse pair among the ones it actually sells -
48
+ the same list the sitemap publishes - so it can never point at an
49
+ empty page. */ }
50
+ { reverse && (
51
+ <Reverse>
52
+ <Heading>{ t('routes_order.links.reverse') }</Heading>
53
+
54
+ <ReverseLink to={ reverse.url }>
55
+ { t('routes_order.links.reverse_link', { from: reverse.from_name, to: reverse.to_name }) }
56
+ </ReverseLink>
57
+ </Reverse>
58
+ ) }
59
+
60
+ { from.length > 0 && (
61
+ <Group>
62
+ <Heading>{ t('routes_order.links.from', { city: fromName ?? from[0].from_name }) }</Heading>
63
+
64
+ <List>
65
+ { from.map(row => (
66
+ <li key={ row.url }>
67
+ <Item to={ row.url }>{ row.to_name }</Item>
68
+ </li>
69
+ )) }
70
+ </List>
71
+ </Group>
72
+ ) }
73
+
74
+ { to.length > 0 && (
75
+ <Group>
76
+ <Heading>{ t('routes_order.links.to', { city: toName ?? to[0].to_name }) }</Heading>
77
+
78
+ <List>
79
+ { to.map(row => (
80
+ <li key={ row.url }>
81
+ <Item to={ row.url }>{ row.from_name }</Item>
82
+ </li>
83
+ )) }
84
+ </List>
85
+ </Group>
86
+ ) }
87
+ </Container>
88
+ );
89
+ };
90
+
91
+ export default Links;
@@ -0,0 +1,75 @@
1
+ import styled from 'styled-components';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ export const Container = styled.section`
5
+ margin-top: 25px;
6
+ `;
7
+
8
+ export const Group = styled.div`
9
+ & + & {
10
+ margin-top: 20px;
11
+ }
12
+ `;
13
+
14
+ export const Heading = styled.h3`
15
+ margin: 0 0 10px;
16
+ font-size: ${ props => props.theme.size.m };
17
+ `;
18
+
19
+ export const List = styled.ul`
20
+ display: flex;
21
+ flex-wrap: wrap;
22
+ gap: 8px;
23
+ margin: 0;
24
+ padding: 0;
25
+ list-style: none;
26
+ `;
27
+
28
+ /**
29
+ * A real router Link, not a styled div with a click handler - the entire
30
+ * point of these blocks is that a crawler follows them.
31
+ */
32
+ export const Item = styled(Link)`
33
+ display: inline-block;
34
+ padding: 6px 12px;
35
+ border-radius: ${ props => props.theme.borderRadius };
36
+ background: ${ props => props.theme.background.neutral };
37
+ color: ${ props => props.theme.font.normal };
38
+ font-size: ${ props => props.theme.size.s };
39
+ white-space: nowrap;
40
+
41
+ &:hover {
42
+ text-decoration: none;
43
+ background: ${ props => props.theme.primary.neutral };
44
+ }
45
+ `;
46
+
47
+ /**
48
+ * The return journey.
49
+ *
50
+ * Edited: Claude - Date: 2026-08-20
51
+ *
52
+ * Set apart from the two "popular routes" lists rather than dropped into
53
+ * one of them: it is a single specific page, not a browse list, and a
54
+ * reader planning a round trip is looking for exactly it.
55
+ */
56
+ export const Reverse = styled.div`
57
+ margin-bottom: 20px;
58
+ padding-bottom: 20px;
59
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
60
+ `;
61
+
62
+ export const ReverseLink = styled(Link)`
63
+ display: inline-block;
64
+ padding: 8px 16px;
65
+ border-radius: ${ props => props.theme.borderRadius };
66
+ background: ${ props => props.theme.primary.neutral };
67
+ color: ${ props => props.theme.font.normal };
68
+ font-size: ${ props => props.theme.size.s };
69
+ font-weight: 700;
70
+
71
+ &:hover {
72
+ text-decoration: none;
73
+ background: ${ props => props.theme.background.neutral };
74
+ }
75
+ `;