@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/Found/Sort/styles.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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:
|
|
19
|
-
flex-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
|
-
|
|
24
|
-
|
|
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
|
package/Found/Summary/styles.ts
CHANGED
|
@@ -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
|
|
package/Links/Links.tsx
ADDED
|
@@ -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;
|
package/Links/styles.ts
ADDED
|
@@ -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
|
+
`;
|