@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 +122 -0
- package/Checkout/Checkout.tsx +280 -0
- package/Checkout/Sidebar/Sidebar.tsx +110 -0
- package/Checkout/Sidebar/styles.ts +44 -0
- package/Found/Empty/Empty.tsx +109 -0
- package/Found/Empty/styles.ts +99 -0
- package/Found/Found.tsx +12 -0
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +13 -4
- package/Found/Route/Route.tsx +25 -14
- package/Found/Route/styles.ts +61 -23
- package/Found/refine.ts +26 -0
- package/RoutesOrder.tsx +45 -23
- package/Step2.tsx +34 -1
- package/Step4/Passenger/Passenger.tsx +10 -10
- package/Step5/Billing/Billing.tsx +8 -7
- package/Step5/Coupons/styles.ts +16 -9
- package/Step5/Payments/styles.ts +7 -4
- package/Step5/Summary/About/About.tsx +66 -16
- package/Step5/Summary/About/styles.ts +81 -0
- package/Steps/Steps.tsx +3 -1
- package/package.json +1 -1
- package/services.ts +46 -4
- package/styles.ts +13 -0
- package/types.ts +24 -0
- package/Step4/Step4.tsx +0 -114
- package/Step5/Step5.tsx +0 -185
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;
|