@autobusal/routes-order 1.9.3 → 1.11.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 CHANGED
@@ -6,6 +6,128 @@ All notable changes to this package are documented here. This project follows
6
6
  > Note: 1.8.0 was published without a changelog entry. The gap is left as-is
7
7
  > rather than reconstructed after the fact.
8
8
 
9
+ ## [1.11.1] - 2026-08-01
10
+
11
+ ### Changed
12
+
13
+ - **Coupon and payment method moved into the sidebar**, coupon directly
14
+ above the totals it changes and the method picker directly above the
15
+ button that acts on it. Both sat several sections away in the main
16
+ column, so applying a coupon changed a number that was off screen.
17
+ - **The results heading names the trip** - "Online Bus tickets from Tirana
18
+ Albania to Thessaloniki Greece" instead of "Select a Departure Schedule".
19
+ Countries are dropped when both ends share one, since "from Tirana
20
+ Albania to Korca Albania" is noise. Read from the city list rather than
21
+ from the results, so the heading is right even when the search finds
22
+ nothing - which is exactly when knowing what was searched for matters.
23
+ - **Order summary**: the operator's logo under the route name, departure
24
+ and arrival icons sitting on the connecting line, and the departure date
25
+ and time in bold - the two things a buyer checks before paying.
26
+
27
+ ### Fixed
28
+
29
+ - The coupon and payment blocks switched layout at a VIEWPORT breakpoint,
30
+ so in the ~290px sidebar they used the wide arrangement and the coupon
31
+ input came out 86px wide. They now wrap on their own width.
32
+
33
+ ## [1.11.0] - 2026-08-01
34
+
35
+ ### Changed
36
+
37
+ - **Passenger details and checkout are one screen.** Split across two
38
+ steps, the buyer typed passenger details with no sight of the price, then
39
+ saw the price on a screen where they could no longer check what they had
40
+ typed - and ticking an add-on changed a total that had already scrolled
41
+ away. The wizard is four steps now, not five.
42
+ - **A sticky sidebar** carries the trip, the ticket price, each add-on, any
43
+ discount, the total due, and the button that commits to it. The button
44
+ follows the chosen method, so it reads "Reserve" for a reservation rather
45
+ than claiming to take a payment.
46
+ - **The trip summary states both ends of each leg**, city and country in
47
+ full, origin above destination. It previously showed one end only - the
48
+ departure point outbound, the arrival point on the return - so a one-way
49
+ buyer reached payment having never been shown where the bus drops them.
50
+ - **Required fields are marked.** The form validated them but never said
51
+ which they were, so the first a buyer heard of it was an error message
52
+ after pressing pay.
53
+
54
+ `usePostOrder` now takes the passenger details in its mutation variables
55
+ rather than as a hook argument: on one screen they are only known at the
56
+ moment pay is pressed, so a closure captured at render time would have sent
57
+ stale state. The two forms stay separate react-hook-form instances - they
58
+ validate different things, and billing is not rendered at all for a
59
+ signed-in buyer - and paying validates them in order, stopping at the first
60
+ failure so the buyer lands on the thing that needs fixing.
61
+
62
+ Verified by completing a real reservation end to end: order 61 / ticket 75
63
+ carried every entered value, seat included.
64
+
65
+ ## [1.10.0] - 2026-08-01
66
+
67
+ ### Added
68
+
69
+ - **A search that finds nothing now answers instead of apologising.** In
70
+ place of the bare "we couldn't find any tickets", the page offers nearby
71
+ departure cities, nearby arrival cities and other dates that have seats.
72
+ Every suggestion is a search obtapi has already confirmed returns
73
+ results on the requested date, quoted with its fare, so a click is never
74
+ a second dead end. Nearby cities are ordered by real distance from the
75
+ city that was asked for - the passenger's problem is getting to the bus,
76
+ not saving two euros.
77
+ - Fetched only once the departure search has come back empty, so a search
78
+ that succeeded never pays for it.
79
+
80
+ Suggestion links do a full page load on purpose: RoutesOrder holds the
81
+ search in component state and Step1 only starts a search when it has not
82
+ already redirected, so a client-side navigation would change the URL and
83
+ leave the empty result set on screen.
84
+
85
+ ## [1.9.6] - 2026-08-01
86
+
87
+ ### Added
88
+
89
+ - **The search form stays on screen while choosing a route.** Adjusting a
90
+ date, a city or the passenger count used to mean "Back to Search", which
91
+ threw the results away along with any filters. Shown only on the
92
+ route-selection screens: past that point the passenger has committed to a
93
+ specific trip, and re-running the search under them would discard details
94
+ they had already typed.
95
+
96
+ ## [1.9.5] - 2026-08-01
97
+
98
+ ### Fixed
99
+
100
+ - **The column header row now lines up with the result cards** - 0px drift
101
+ on all five columns, and content-independent. It had 4px horizontal
102
+ padding against the card's 15px and no column gap against the card's
103
+ 15px, which pushed every label right of the data it named (Duration was
104
+ out by 34px).
105
+ - **The card's two location columns are now equal width.** `flex: 1` with
106
+ the default `min-width: auto` meant they sized to their content, so the
107
+ arrival column (long city plus long stop name) took 215px while the
108
+ departure column got 155px. Beyond breaking any header alignment, this
109
+ let one long stop name steal width from the other side of the card.
110
+
111
+ ## [1.9.4] - 2026-08-01
112
+
113
+ ### Changed
114
+
115
+ - **Fare decimals ride as superscript** and the currency is set smaller.
116
+ On a fare board the cents are almost never what anyone is comparing, and
117
+ at full size they took a third of the width of the widest thing on the
118
+ card. Split from obtapi's already-formatted string rather than
119
+ re-formatted from `price.value`, so the label's own currency and number
120
+ formatting stay the single source of truth - an unexpected format falls
121
+ back to rendering the string whole.
122
+ - **The taxes/passenger line is smaller and tucked against the price**,
123
+ which reads as one unit and frees the room the Select button needed.
124
+ - **Amenities moved into the details row** as its first column, so route,
125
+ transit and policies sit beside them - four columns instead of a separate
126
+ full-width band under every card.
127
+ - **Amenities are icon-only on this page**, with the name as a hover
128
+ tooltip (`compact` on `RouteFeature`). Eight amenities spelled out took
129
+ more width than everything else in that row put together.
130
+
9
131
  ## [1.9.3] - 2026-08-01
10
132
 
11
133
  ### Changed
@@ -0,0 +1,280 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { useForm } from 'react-hook-form';
4
+ import { useNavigate } from 'react-router-dom';
5
+ import { RiShoppingCartLine } from 'react-icons/ri';
6
+ import { AiOutlineCaretLeft } from 'react-icons/ai';
7
+ import { useBusOccupied } from '@autobusal/hooks';
8
+ import { Button } from '@autobusal/common';
9
+ import Steps from '../Steps/Steps';
10
+ import Loading from '../Step4/Loading';
11
+ import Passengers from '../Step4/Passengers/Passengers';
12
+ import checkAge from '../Step4/checkAge';
13
+ import Coupons from '../Step5/Coupons/Coupons';
14
+ import Addons from '../Step5/Addons/Addons';
15
+ import Billing from '../Step5/Billing/Billing';
16
+ import Payments from '../Step5/Payments/Payments';
17
+ import Sidebar from './Sidebar/Sidebar';
18
+ import { getTotal, convertCoupon } from '../Step5/utilities';
19
+ import { Title, Actions, Results, Listing } from '../styles';
20
+ import { RoutesSearchForm } from '@autobusal/routes-search/types';
21
+ import { PersonData, BillingData } from '@autobusal/providers/types/persons';
22
+ import { FoundData } from '@autobusal/providers/types/routes';
23
+ import { CouponData } from '@autobusal/providers/types/orders';
24
+ import { TotalData } from '../types';
25
+ import { useUserStore } from '@autobusal/providers/stores/user';
26
+ import { useGetSettings } from '@autobusal/providers/services';
27
+ import { usePostOrder } from '../services';
28
+
29
+ interface Props {
30
+ values?: PersonData
31
+ step1: RoutesSearchForm
32
+ step2: FoundData
33
+ step3?: FoundData
34
+ isPartner?: boolean
35
+ t: TFunction<'common'>
36
+ /**
37
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
38
+ * Hands the typed passenger details back up before leaving, so going
39
+ * back to change a route and returning does not wipe the form. Step4
40
+ * used to get this for free by only ever leaving via a successful
41
+ * submit; this screen can be left mid-edit.
42
+ */
43
+ onStore: (data: PersonData) => void
44
+ onBack: (step: number) => void
45
+ }
46
+
47
+ // Routes without a passenger_fields config keep the pre-feature form
48
+ const DEFAULT_FIELDS = ['sex', 'passport'];
49
+
50
+ /**
51
+ * Passenger details and payment on one screen.
52
+ *
53
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
54
+ *
55
+ * These were two steps. Splitting them meant the buyer typed passenger
56
+ * details with no sight of the price, then saw the price on a screen where
57
+ * they could no longer check what they had typed - and add-ons changed the
58
+ * total on a page whose total had already scrolled away.
59
+ *
60
+ * The two forms stay separate react-hook-form instances rather than being
61
+ * merged into one. They validate different things under different rules,
62
+ * and billing is not even rendered for a signed-in user; one combined form
63
+ * would have to reproduce that conditionality itself. Paying validates them
64
+ * in order and stops at the first failure, so the buyer is taken to the
65
+ * first thing that needs fixing rather than the last.
66
+ */
67
+ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }: Props): JSX.Element => {
68
+ const passenger = useForm<PersonData>();
69
+ const billing = useForm<BillingData>();
70
+
71
+ const [ coupon, setCoupon ] = useState<CouponData | undefined>(undefined);
72
+ const [ total, setTotal ] = useState<TotalData>(getTotal(step2, step3));
73
+ const [ action, setAction ] = useState<string | undefined>(undefined);
74
+
75
+ const [ addonWhatsapp, setAddonWhatsapp ] = useState<boolean>(false);
76
+ const [ addonWhatsappNumber, setAddonWhatsappNumber ] = useState<string>('');
77
+ const [ addonTelegram, setAddonTelegram ] = useState<boolean>(false);
78
+
79
+ const { data: UserData } = useUserStore();
80
+ const { data: SettingsData } = useGetSettings();
81
+
82
+ const navigate = useNavigate();
83
+
84
+ // one form serves both legs of a return trip, so it collects the UNION of
85
+ // what either route requires (mirrored by the backend rules)
86
+ const fields = Array.from(new Set([
87
+ ...(step2.trip?.passenger_fields ?? DEFAULT_FIELDS),
88
+ ...(step3 !== undefined ? (step3.trip?.passenger_fields ?? DEFAULT_FIELDS) : [])
89
+ ]));
90
+
91
+ const busFrom = useBusOccupied(step1.departure, step2.id as number);
92
+ const busTo = useBusOccupied(step1._return, step3?.id as (number | undefined));
93
+
94
+ const currency = total.display.split(' ')[1] ?? '';
95
+
96
+ const addonItems = [
97
+ addonWhatsapp && SettingsData?.addons?.whatsapp.available ? {
98
+ label: t('routes_order.step5.addons.whatsapp.label'),
99
+ price: SettingsData.addons.whatsapp.price
100
+ } : null,
101
+ addonTelegram && SettingsData?.addons?.telegram.available ? {
102
+ label: t('routes_order.step5.addons.telegram.label'),
103
+ price: SettingsData.addons.telegram.price
104
+ } : null
105
+ ].filter((item): item is { label: string, price: number } => item !== null);
106
+
107
+ const addonsValue = addonItems.reduce((sum, item) => sum + item.price, 0);
108
+
109
+ const grandTotal: TotalData = {
110
+ display: `${ total.value + addonsValue } ${ currency }`,
111
+ value: total.value + addonsValue
112
+ };
113
+
114
+ const { mutate: OrderSend, isPending } = usePostOrder(step1, step2, step3, coupon, action, {
115
+ whatsapp: addonWhatsapp,
116
+ whatsappNumber: addonWhatsappNumber,
117
+ telegram: addonTelegram
118
+ });
119
+
120
+ const onApplyCoupon = (data: CouponData): void => {
121
+ setTotal(convertCoupon(data));
122
+ setCoupon(data);
123
+ };
124
+
125
+ const onAction = useCallback((action: string): void => {
126
+ setAction(action);
127
+ }, []);
128
+
129
+ const onUpdate = (name: string, value: (string | number)): void => {
130
+ passenger.setValue(name, value);
131
+ passenger.setError(name, {});
132
+ };
133
+
134
+ /**
135
+ * Validate everything, then send. Each stage returns the data it
136
+ * collected or undefined, so a failure stops the chain with the offending
137
+ * form's own error messages already on screen.
138
+ */
139
+ const onPay = async (): Promise<void> => {
140
+ if (action === undefined) {
141
+ alert(t('routes_order.step5.choose_payment'));
142
+
143
+ return;
144
+ }
145
+
146
+ if (addonWhatsapp && addonWhatsappNumber.trim().length < 5) {
147
+ alert(t('routes_order.step5.addons.whatsapp.phone_error'));
148
+
149
+ return;
150
+ }
151
+
152
+ let person: PersonData | undefined;
153
+
154
+ await passenger.handleSubmit((data) => { person = data; })();
155
+
156
+ if (person === undefined || !checkAge(step1, person, t)) {
157
+ return;
158
+ }
159
+
160
+ // billing is only rendered for a guest, so there is nothing to validate
161
+ // for a signed-in buyer - the API fills it from the account
162
+ let details = {} as BillingData;
163
+
164
+ if (UserData === undefined) {
165
+ let collected: BillingData | undefined;
166
+
167
+ await billing.handleSubmit((data) => { collected = data; })();
168
+
169
+ if (collected === undefined) {
170
+ return;
171
+ }
172
+
173
+ details = collected;
174
+ }
175
+
176
+ OrderSend({ billing: details, person }, {
177
+ onSuccess: (data) => {
178
+ if (data === undefined) {
179
+ return;
180
+ }
181
+
182
+ const hash = step1.type === 'return' ? data.return : data.departure;
183
+
184
+ navigate(`/orders/process/${ action }/${ hash }`);
185
+ }
186
+ });
187
+ };
188
+
189
+ if (busFrom === undefined || busTo === undefined) {
190
+ return <Loading t={ t } />;
191
+ }
192
+
193
+ return (
194
+ <>
195
+ <Title>
196
+ <RiShoppingCartLine />
197
+ { t('routes_order.step5.title') }
198
+ </Title>
199
+
200
+ <Steps value={ 4 } />
201
+
202
+ <Results>
203
+ <Listing>
204
+ <Passengers
205
+ values={ values }
206
+ step1={ step1 }
207
+ fields={ fields }
208
+ busFrom={ busFrom }
209
+ busTo={ busTo }
210
+ t={ t }
211
+ errors={ passenger.formState.errors }
212
+ refs={ passenger.register }
213
+ onUpdate={ onUpdate }
214
+ />
215
+
216
+ <Addons
217
+ data={ SettingsData?.addons }
218
+ currency={ currency }
219
+ whatsapp={ addonWhatsapp }
220
+ whatsappNumber={ addonWhatsappNumber }
221
+ telegram={ addonTelegram }
222
+ t={ t }
223
+ onChangeWhatsapp={ setAddonWhatsapp }
224
+ onChangeWhatsappNumber={ setAddonWhatsappNumber }
225
+ onChangeTelegram={ setAddonTelegram }
226
+ />
227
+
228
+ { UserData === undefined && (
229
+ <Billing t={ t } errors={ billing.formState.errors } refs={ billing.register } />
230
+ ) }
231
+ </Listing>
232
+
233
+ <Sidebar
234
+ step1={ step1 }
235
+ step2={ step2 }
236
+ step3={ step3 }
237
+ fare={ total.display }
238
+ addons={ addonItems.map(item => ({ label: item.label, value: `${ item.price } ${ currency }` })) }
239
+ coupon={ coupon }
240
+ total={ grandTotal }
241
+ action={ action }
242
+ pending={ isPending }
243
+ t={ t }
244
+ onPay={ onPay }
245
+ payments={ (
246
+ <Payments
247
+ selected={ action ?? '' }
248
+ isPartner={ isPartner ?? false }
249
+ t={ t }
250
+ onChange={ onAction }
251
+ />
252
+ ) }
253
+ >
254
+ <Coupons current={ coupon } step2={ step2 } total={ total } t={ t } onApplied={ onApplyCoupon } />
255
+ </Sidebar>
256
+ </Results>
257
+
258
+ { isPartner === undefined && (
259
+ <Actions>
260
+ <Button
261
+ type="button"
262
+ subtype="secondary"
263
+ text={ <><AiOutlineCaretLeft /> { t('routes_order.step4.back') }</> }
264
+ noMargin
265
+ onClick={ () => {
266
+ // keep whatever has been typed, even though it has not been
267
+ // validated - coming back to a half-filled form is far better
268
+ // than coming back to an empty one
269
+ onStore(passenger.getValues());
270
+
271
+ onBack(step1._return ? 3 : 2);
272
+ } }
273
+ />
274
+ </Actions>
275
+ ) }
276
+ </>
277
+ );
278
+ };
279
+
280
+ export default Checkout;
@@ -0,0 +1,110 @@
1
+ import { TFunction } from 'i18next';
2
+ import { MdDone } from 'react-icons/md';
3
+ import { AiOutlineInfoCircle } from 'react-icons/ai';
4
+ import { Button } from '@autobusal/common';
5
+ import About from '../../Step5/Summary/About/About';
6
+ import Amount from '../../Step5/Summary/Amount/Amount';
7
+ import { Container, Panel, Actions, Notice } from './styles';
8
+ import { SubTitle } from '../../styles';
9
+ import { Information } from '../../Step5/Summary/styles';
10
+ import { RoutesSearchForm } from '@autobusal/routes-search/types';
11
+ import { FoundData } from '@autobusal/providers/types/routes';
12
+ import { CouponData } from '@autobusal/providers/types/orders';
13
+ import { TotalData } from '../../types';
14
+
15
+ interface AddonItem {
16
+ label: string
17
+ value: string
18
+ }
19
+
20
+ interface Props {
21
+ step1: RoutesSearchForm
22
+ step2: FoundData
23
+ step3?: FoundData
24
+ fare: string
25
+ addons: AddonItem[]
26
+ coupon?: CouponData
27
+ total: TotalData
28
+ action?: string
29
+ pending: boolean
30
+ t: TFunction<'common'>
31
+ onPay: () => void
32
+ /** coupon form, rendered directly above the totals it affects */
33
+ children?: React.ReactNode
34
+ /** payment-method picker, rendered directly above the button that uses it */
35
+ payments?: React.ReactNode
36
+ }
37
+
38
+ /**
39
+ * The money, the trip, and the button that commits to both.
40
+ *
41
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
42
+ *
43
+ * Everything a buyer needs to check before paying, kept beside the form
44
+ * rather than above it. Previously the total lived at the top of a long
45
+ * page and scrolled away while add-ons were being ticked - so the number
46
+ * changed out of sight of the person it was changing for.
47
+ */
48
+ const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, action, pending, t, onPay, children, payments }: Props): JSX.Element => (
49
+ <Container>
50
+ <Panel className="box">
51
+ <SubTitle>
52
+ <AiOutlineInfoCircle />
53
+ { t('routes_order.step5.summary.title') }
54
+ </SubTitle>
55
+
56
+ <Information>
57
+ <About type="departure" step1={ step1 } data={ step2 } t={ t } />
58
+ <About type="return" step1={ step1 } data={ step3 } t={ t } />
59
+ </Information>
60
+
61
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
62
+ Coupon sits ABOVE the total, where its effect is visible the
63
+ moment it applies. In the main column it was several sections
64
+ away from the number it changes. */ }
65
+ { children }
66
+
67
+ <div>
68
+ <Amount type="addon" label={ t('routes_order.step5.summary.fare') } value={ fare } t={ t } />
69
+
70
+ { addons.map(item => (
71
+ <Amount key={ item.label } type="addon" label={ item.label } value={ item.value } t={ t } />
72
+ )) }
73
+
74
+ { coupon && <Amount type="discount" code={ coupon.code } value={ coupon.discount } t={ t } /> }
75
+
76
+ <Amount type="total" value={ total.display } t={ t } />
77
+ </div>
78
+
79
+ <Actions>
80
+ { /* the method picker belongs next to the button that acts on it,
81
+ not at the far end of a long form */ }
82
+ { payments }
83
+
84
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
85
+ The label follows the chosen method, so the button says what it
86
+ is about to do - "Reserve" does not take money, and a button
87
+ reading "Make Payment" for it would be a lie. */ }
88
+ <Button
89
+ loading={ pending }
90
+ noMargin
91
+ text={ (
92
+ <>
93
+ <MdDone />
94
+ { action === 'reserve'
95
+ ? t('routes_order.step5.reserve')
96
+ : t('routes_order.step5.next') }
97
+ </>
98
+ ) }
99
+ onClick={ onPay }
100
+ />
101
+
102
+ { action === undefined && (
103
+ <Notice>{ t('routes_order.step5.choose_payment') }</Notice>
104
+ ) }
105
+ </Actions>
106
+ </Panel>
107
+ </Container>
108
+ );
109
+
110
+ export default Sidebar;
@@ -0,0 +1,44 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
5
+ *
6
+ * Sticky, so the trip, the running total and the pay button stay in view
7
+ * for the whole length of the passenger form. The point of merging the two
8
+ * steps was that somebody filling in four passengers can still see what
9
+ * they are about to be charged.
10
+ */
11
+ export const Container = styled.aside`
12
+ min-width: 0;
13
+
14
+ // 1280 to match Results in ../../styles.ts, which is where the row
15
+ // layout actually starts
16
+ @media (min-width: 1280px) {
17
+ flex: 0 0 340px;
18
+ position: sticky;
19
+ top: 15px;
20
+ max-height: calc(100vh - 30px);
21
+ overflow-y: auto;
22
+ }
23
+ `;
24
+
25
+ export const Panel = styled.div`
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: 12px;
29
+ `;
30
+
31
+ export const Actions = styled.div`
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 10px;
35
+ padding-top: 12px;
36
+ border-top: 1px solid ${ props => props.theme.background.neutral };
37
+ `;
38
+
39
+ export const Notice = styled.p`
40
+ margin: 0;
41
+ font-size: ${ props => props.theme.size.xs };
42
+ color: ${ props => props.theme.font.faded };
43
+ text-align: center;
44
+ `;
@@ -0,0 +1,109 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Container, Headline, Title, Lead, Group, GroupTitle, Options, Option, OptionName, OptionMeta, OptionPrice } from './styles';
3
+ import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
+ import { AlternativesData, NearbyCity, FoundDay } from '../../types';
5
+
6
+ interface Props {
7
+ step1: RoutesSearchForm
8
+ data?: AlternativesData
9
+ loading: boolean
10
+ t: TFunction<'common'>
11
+ }
12
+
13
+ /**
14
+ * A search that found nothing, answered rather than apologised for.
15
+ *
16
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
17
+ *
18
+ * "We couldn't find any tickets" is where people leave. Every suggestion
19
+ * here is a real search that obtapi has already confirmed returns seats on
20
+ * the requested date, with the fare it would cost - so a click is never a
21
+ * second dead end.
22
+ *
23
+ * Nearby cities are ordered by actual distance from the city that was
24
+ * asked for, which is why a 29km alternative is offered before a 73km one
25
+ * even when the further city is cheaper: the passenger's problem here is
26
+ * getting to the bus, not saving two euros.
27
+ */
28
+ const Empty = ({ step1, data, loading, t }: Props): JSX.Element => {
29
+ // obtapi wants DD/MM/YYYY, the URL carries DD-MM-YYYY
30
+ const asPath = (date: string): string => date.replace(/\//g, '-');
31
+
32
+ const link = (from: string, to: string, departure: string): string => ([
33
+ '/bus-lines',
34
+ from,
35
+ to,
36
+ asPath(departure),
37
+ step1._return ? asPath(step1._return) : 'none',
38
+ step1.type ?? 'departure',
39
+ String(step1.adults),
40
+ String(step1.children),
41
+ String(step1.babies)
42
+ ].join('/'));
43
+
44
+ const city = (item: NearbyCity, side: 'from' | 'to'): JSX.Element => (
45
+ <Option
46
+ key={ item.city.id }
47
+ reloadDocument
48
+ to={ side === 'from'
49
+ ? link(item.city.slug, step1.to, step1.departure)
50
+ : link(step1.from, item.city.slug, step1.departure) }
51
+ >
52
+ <OptionName title={ item.city.name }>{ item.city.name }</OptionName>
53
+ <OptionMeta>{ t('routes_order.step2.empty.distance', { km: item.distance }) }</OptionMeta>
54
+ <OptionPrice>{ item.from_price_display }</OptionPrice>
55
+ </Option>
56
+ );
57
+
58
+ const day = (item: FoundDay): JSX.Element => (
59
+ <Option key={ item.id } reloadDocument to={ link(step1.from, step1.to, item.day) }>
60
+ <OptionName>{ item.day }</OptionName>
61
+ <OptionMeta>{ t('routes_order.step2.empty.available') }</OptionMeta>
62
+ { item.from_price_display && <OptionPrice>{ item.from_price_display }</OptionPrice> }
63
+ </Option>
64
+ );
65
+
66
+ const hasFrom = (data?.from.length ?? 0) > 0;
67
+ const hasTo = (data?.to.length ?? 0) > 0;
68
+ const hasDates = (data?.dates.length ?? 0) > 0;
69
+
70
+ return (
71
+ <Container className="box">
72
+ <Headline>
73
+ <Title>{ t('routes_order.step2.empty.title') }</Title>
74
+
75
+ { /* while the alternatives are still being fetched, say nothing
76
+ about them - promising suggestions that then turn out not to
77
+ exist is worse than the plain message */ }
78
+ <Lead>
79
+ { loading || hasFrom || hasTo || hasDates
80
+ ? t('routes_order.step2.empty.lead')
81
+ : t('routes_order.step2.empty.lead_none') }
82
+ </Lead>
83
+ </Headline>
84
+
85
+ { hasDates && (
86
+ <Group>
87
+ <GroupTitle>{ t('routes_order.step2.empty.dates') }</GroupTitle>
88
+ <Options>{ data?.dates.map(day) }</Options>
89
+ </Group>
90
+ ) }
91
+
92
+ { hasFrom && (
93
+ <Group>
94
+ <GroupTitle>{ t('routes_order.step2.empty.from') }</GroupTitle>
95
+ <Options>{ data?.from.map(item => city(item, 'from')) }</Options>
96
+ </Group>
97
+ ) }
98
+
99
+ { hasTo && (
100
+ <Group>
101
+ <GroupTitle>{ t('routes_order.step2.empty.to') }</GroupTitle>
102
+ <Options>{ data?.to.map(item => city(item, 'to')) }</Options>
103
+ </Group>
104
+ ) }
105
+ </Container>
106
+ );
107
+ };
108
+
109
+ export default Empty;