@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
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { useCallback, useState, useEffect } 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, getAvailablePayments, currencyOf } 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, useGetFlex, useGetPayments, useGetSavedPassengers } from '../services';
|
|
28
|
+
import { addon as commerceAddon, beginCheckout, stash } from '@autobusal/providers/Setup/commerce';
|
|
29
|
+
import { item as commerceItem } from '../commerce';
|
|
30
|
+
import { error as notifyError } from '@autobusal/utilities';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
values?: PersonData
|
|
34
|
+
step1: RoutesSearchForm
|
|
35
|
+
step2: FoundData
|
|
36
|
+
step3?: FoundData
|
|
37
|
+
isPartner?: boolean
|
|
38
|
+
t: TFunction<'common'>
|
|
39
|
+
/**
|
|
40
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
41
|
+
* Hands the typed passenger details back up before leaving, so going
|
|
42
|
+
* back to change a route and returning does not wipe the form. Step4
|
|
43
|
+
* used to get this for free by only ever leaving via a successful
|
|
44
|
+
* submit; this screen can be left mid-edit.
|
|
45
|
+
*/
|
|
46
|
+
onStore: (data: PersonData) => void
|
|
47
|
+
onBack: (step: number) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Routes without a passenger_fields config keep the pre-feature form
|
|
51
|
+
const DEFAULT_FIELDS = ['sex', 'passport'];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Edited: Claude - Date: 2026-08-21
|
|
55
|
+
*
|
|
56
|
+
* THE THREE COLUMNS A REGISTERED CUSTOMER CAN ARRIVE HERE UNABLE TO FILL,
|
|
57
|
+
* as `checkout field => the profile field that answers it`.
|
|
58
|
+
*
|
|
59
|
+
* `orders_contact`.`phone`/`address`/`city` are NOT NULL with no default;
|
|
60
|
+
* the same three on `visitors` are nullable, and registration collects a
|
|
61
|
+
* name, an email and a password and nothing else. So an incomplete profile
|
|
62
|
+
* is the ORDINARY state of a self-registered customer rather than an edge
|
|
63
|
+
* case - and until today it meant a 500 fired after the seats had already
|
|
64
|
+
* been held, and then (once that was patched) a booking handed to the
|
|
65
|
+
* operator with no phone number for the passenger.
|
|
66
|
+
*
|
|
67
|
+
* The customer is asked here instead, in the guest form's own fields, for
|
|
68
|
+
* the gaps and nothing else. Mirrors Orders\Make\Contact::BILLING and
|
|
69
|
+
* Orders\Make\Rules on the API side, which make exactly this set required
|
|
70
|
+
* for exactly this case.
|
|
71
|
+
*/
|
|
72
|
+
const PROFILE_BILLING: Record<string, 'phone' | 'address' | 'city'> = {
|
|
73
|
+
bill_phone: 'phone',
|
|
74
|
+
bill_address: 'address',
|
|
75
|
+
bill_city: 'city'
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const empty = (value?: string | null): boolean => (
|
|
79
|
+
value === undefined || value === null || String(value).trim() === ''
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Passenger details and payment on one screen.
|
|
84
|
+
*
|
|
85
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
86
|
+
*
|
|
87
|
+
* These were two steps. Splitting them meant the buyer typed passenger
|
|
88
|
+
* details with no sight of the price, then saw the price on a screen where
|
|
89
|
+
* they could no longer check what they had typed - and add-ons changed the
|
|
90
|
+
* total on a page whose total had already scrolled away.
|
|
91
|
+
*
|
|
92
|
+
* The two forms stay separate react-hook-form instances rather than being
|
|
93
|
+
* merged into one. They validate different things under different rules,
|
|
94
|
+
* and billing is not even rendered for a signed-in user; one combined form
|
|
95
|
+
* would have to reproduce that conditionality itself. Paying validates them
|
|
96
|
+
* in order and stops at the first failure, so the buyer is taken to the
|
|
97
|
+
* first thing that needs fixing rather than the last.
|
|
98
|
+
*/
|
|
99
|
+
const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }: Props): JSX.Element => {
|
|
100
|
+
const passenger = useForm<PersonData>();
|
|
101
|
+
const billing = useForm<BillingData>();
|
|
102
|
+
|
|
103
|
+
const [ coupon, setCoupon ] = useState<CouponData | undefined>(undefined);
|
|
104
|
+
const [ total, setTotal ] = useState<TotalData>(getTotal(step2, step3));
|
|
105
|
+
const [ action, setAction ] = useState<string | undefined>(undefined);
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-07
|
|
109
|
+
*
|
|
110
|
+
* Whether there is a payment CHOICE to be made, which decides both whether
|
|
111
|
+
* the picker draws itself and what the button says.
|
|
112
|
+
*
|
|
113
|
+
* The same query the picker runs, under the same key, so react-query
|
|
114
|
+
* answers it from cache - this reads the answer, it does not fetch it a
|
|
115
|
+
* second time.
|
|
116
|
+
*/
|
|
117
|
+
const { data: paymentsData } = useGetPayments(step2.id);
|
|
118
|
+
|
|
119
|
+
const singleMethod = getAvailablePayments(paymentsData, isPartner ?? false).length === 1;
|
|
120
|
+
|
|
121
|
+
const [ addonWhatsapp, setAddonWhatsapp ] = useState<boolean>(false);
|
|
122
|
+
const [ addonWhatsappNumber, setAddonWhatsappNumber ] = useState<string>('');
|
|
123
|
+
const [ addonTelegram, setAddonTelegram ] = useState<boolean>(false);
|
|
124
|
+
const [ addonSms, setAddonSms ] = useState<boolean>(false);
|
|
125
|
+
const [ addonSmsNumber, setAddonSmsNumber ] = useState<string>('');
|
|
126
|
+
const [ addonFlex, setAddonFlex ] = useState<boolean>(false);
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* Edited: Claude - Date: 2026-08-26
|
|
130
|
+
*
|
|
131
|
+
* The seat-selection add-on. Unlike the checkbox add-ons above there is
|
|
132
|
+
* nothing to tick: wanting it IS picking a seat. `seatsPicked` is the
|
|
133
|
+
* live hand-picked count, reported up by Passengers as choices are made
|
|
134
|
+
* and taken back, and it prices the summary line; the server recomputes
|
|
135
|
+
* the real charge from its own count (Buses\Assign) regardless.
|
|
136
|
+
*/
|
|
137
|
+
const [ seatsPicked, setSeatsPicked ] = useState<number>(0);
|
|
138
|
+
|
|
139
|
+
const { data: UserData } = useUserStore();
|
|
140
|
+
const { data: SettingsData } = useGetSettings();
|
|
141
|
+
|
|
142
|
+
/*
|
|
143
|
+
* Edited: Claude - Date: 2026-08-21
|
|
144
|
+
*
|
|
145
|
+
* WHAT THE BILLING BLOCK ASKS THIS BUYER FOR.
|
|
146
|
+
*
|
|
147
|
+
* A guest is asked for the whole thing, as always (`gaps` stays
|
|
148
|
+
* undefined, which is what Billing reads as "the whole form").
|
|
149
|
+
*
|
|
150
|
+
* A signed-in CUSTOMER is asked only for the profile columns that cannot
|
|
151
|
+
* answer for themselves - see PROFILE_BILLING above. Only a customer: an
|
|
152
|
+
* agent or an operator selling at the counter writes no `orders_contact`
|
|
153
|
+
* row at all, so there is nothing to ask them for.
|
|
154
|
+
*
|
|
155
|
+
* Gated on the profile actually being loaded. The user store persists
|
|
156
|
+
* only a display-only stub to localStorage (see stores/user.ts) and
|
|
157
|
+
* re-fetches the rest on mount, so `visitor` is briefly absent on a cold
|
|
158
|
+
* start - and treating "not loaded yet" as "empty" would flash three
|
|
159
|
+
* required fields at customers whose profile is complete. The wizard
|
|
160
|
+
* reaches this screen several navigations after the app mounts, by which
|
|
161
|
+
* time the bootstrap has long since landed.
|
|
162
|
+
*/
|
|
163
|
+
const gaps = (UserData !== undefined && UserData.type === 'visitor' && UserData.visitor !== undefined)
|
|
164
|
+
? Object.keys(PROFILE_BILLING).filter(field => empty(UserData.visitor?.[PROFILE_BILLING[field]]))
|
|
165
|
+
: undefined;
|
|
166
|
+
|
|
167
|
+
// the guest form, or a customer with something still to fill in
|
|
168
|
+
const askBilling = UserData === undefined || (gaps !== undefined && gaps.length > 0);
|
|
169
|
+
|
|
170
|
+
const navigate = useNavigate();
|
|
171
|
+
|
|
172
|
+
// one form serves both legs of a return trip, so it collects the UNION of
|
|
173
|
+
// what either route requires (mirrored by the backend rules)
|
|
174
|
+
const fields = Array.from(new Set([
|
|
175
|
+
...(step2.trip?.passenger_fields ?? DEFAULT_FIELDS),
|
|
176
|
+
...(step3 !== undefined ? (step3.trip?.passenger_fields ?? DEFAULT_FIELDS) : [])
|
|
177
|
+
]));
|
|
178
|
+
|
|
179
|
+
const busFrom = useBusOccupied(step1.departure, step2.id as number);
|
|
180
|
+
const busTo = useBusOccupied(step1._return, step3?.id as (number | undefined));
|
|
181
|
+
|
|
182
|
+
// Edited: Claude - Date: 2026-08-28 (sweep, low): the currency is the
|
|
183
|
+
// LAST token - split(' ')[1] read '250.00' out of '1 250.00 EUR'
|
|
184
|
+
const currency = currencyOf(total.display);
|
|
185
|
+
|
|
186
|
+
// the brand sells seat choice: picking turns optional (auto-assign is the
|
|
187
|
+
// free default) and each hand-picked seat costs this
|
|
188
|
+
const seatsOffer = SettingsData?.addons?.seats?.available ? SettingsData.addons.seats : undefined;
|
|
189
|
+
|
|
190
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
191
|
+
// Flexible Ticket, for the routes actually chosen. Asked for BOTH legs at
|
|
192
|
+
// once: the price is computed on the combined fare and an operator who
|
|
193
|
+
// will not amend one half removes the offer from the whole booking, so a
|
|
194
|
+
// per-leg question could not be answered honestly.
|
|
195
|
+
const { data: FlexData } = useGetFlex(
|
|
196
|
+
step2.price.id as number,
|
|
197
|
+
step3?.price.id as (number | undefined),
|
|
198
|
+
step1.adults ?? 1,
|
|
199
|
+
step1.children ?? 0,
|
|
200
|
+
step1.babies ?? 0,
|
|
201
|
+
// the coupon-adjusted fare, so the quote and the charge share a base
|
|
202
|
+
total.value
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* "Book somebody you have booked before" - saved passengers.
|
|
207
|
+
*
|
|
208
|
+
* Edited: Claude - Date: 2026-08-20
|
|
209
|
+
*
|
|
210
|
+
* Fetched ONCE here and drilled down rather than asked for inside each
|
|
211
|
+
* passenger block: a family of five would otherwise be five requests for
|
|
212
|
+
* one list, and the list is the same list.
|
|
213
|
+
*
|
|
214
|
+
* ONLY FOR A SIGNED-IN CUSTOMER. The endpoint is `role:visitor`, so a
|
|
215
|
+
* guest and a member of staff both get a 404 - a deliberate refusal that
|
|
216
|
+
* confirms nothing (see obtapi's Account\PassengersController). Deciding
|
|
217
|
+
* that here, from a fact the app already knows, means the request is never
|
|
218
|
+
* made for them and there is no failed query on a checkout screen to
|
|
219
|
+
* accidentally surface.
|
|
220
|
+
*
|
|
221
|
+
* The price ids go with it so every row comes back saying what THIS
|
|
222
|
+
* journey would still have to ask for - see the chooser.
|
|
223
|
+
*/
|
|
224
|
+
const isCustomer = UserData?.type === 'visitor';
|
|
225
|
+
|
|
226
|
+
const { data: SavedData } = useGetSavedPassengers(
|
|
227
|
+
step2.price.id as number,
|
|
228
|
+
step3?.price.id as (number | undefined),
|
|
229
|
+
isCustomer
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// undefined until the list has landed, which is also what a guest gets and
|
|
233
|
+
// is exactly the right answer for both: nothing about the feature renders
|
|
234
|
+
// until there is an account behind it with an answer
|
|
235
|
+
const saved = isCustomer ? (SavedData?.data ?? []) : undefined;
|
|
236
|
+
|
|
237
|
+
const addonItems = [
|
|
238
|
+
addonWhatsapp && SettingsData?.addons?.whatsapp.available ? {
|
|
239
|
+
label: t('routes_order.step5.addons.whatsapp.label'),
|
|
240
|
+
price: SettingsData.addons.whatsapp.price
|
|
241
|
+
} : null,
|
|
242
|
+
addonTelegram && SettingsData?.addons?.telegram.available ? {
|
|
243
|
+
label: t('routes_order.step5.addons.telegram.label'),
|
|
244
|
+
price: SettingsData.addons.telegram.price
|
|
245
|
+
} : null,
|
|
246
|
+
addonSms && SettingsData?.addons?.sms?.available ? {
|
|
247
|
+
label: t('routes_order.step5.addons.sms.label'),
|
|
248
|
+
price: SettingsData.addons.sms.price
|
|
249
|
+
} : null,
|
|
250
|
+
addonFlex && FlexData?.available && FlexData.price !== undefined ? {
|
|
251
|
+
label: t('routes_order.step5.flex.title'),
|
|
252
|
+
price: FlexData.price
|
|
253
|
+
} : null,
|
|
254
|
+
seatsOffer && seatsPicked > 0 ? {
|
|
255
|
+
label: `${ t('seats.fee_line', { ns: 'common' }) } (${ seatsPicked })`,
|
|
256
|
+
price: seatsOffer.price * seatsPicked
|
|
257
|
+
} : null
|
|
258
|
+
].filter((item): item is { label: string, price: number } => item !== null);
|
|
259
|
+
|
|
260
|
+
const addonsValue = addonItems.reduce((sum, item) => sum + item.price, 0);
|
|
261
|
+
|
|
262
|
+
const grandTotal: TotalData = {
|
|
263
|
+
display: `${ total.value + addonsValue } ${ currency }`,
|
|
264
|
+
value: total.value + addonsValue
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
269
|
+
*
|
|
270
|
+
* Here rather than in RoutesOrder because this screen already derives the
|
|
271
|
+
* currency from its own formatted total - the public settings payload
|
|
272
|
+
* carries none - and because it mounts exactly once per arrival at
|
|
273
|
+
* checkout, which is the cadence this event wants.
|
|
274
|
+
*/
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const items = [commerceItem(step2), ...(step3 ? [commerceItem(step3)] : [])];
|
|
277
|
+
|
|
278
|
+
beginCheckout(total.value, items, currency);
|
|
279
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
282
|
+
const { mutate: OrderSend, isPending } = usePostOrder(step1, step2, step3, coupon, action, {
|
|
283
|
+
whatsapp: addonWhatsapp,
|
|
284
|
+
whatsappNumber: addonWhatsappNumber,
|
|
285
|
+
telegram: addonTelegram,
|
|
286
|
+
sms: addonSms,
|
|
287
|
+
seats: seatsOffer !== undefined,
|
|
288
|
+
smsNumber: addonSmsNumber,
|
|
289
|
+
flex: addonFlex
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const onApplyCoupon = (data: CouponData): void => {
|
|
293
|
+
setTotal(convertCoupon(data));
|
|
294
|
+
setCoupon(data);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const onAction = useCallback((action: string): void => {
|
|
298
|
+
setAction(action);
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
const onUpdate = (name: string, value: (string | number)): void => {
|
|
302
|
+
passenger.setValue(name, value);
|
|
303
|
+
passenger.setError(name, {});
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Validate everything, then send. Each stage returns the data it
|
|
308
|
+
* collected or undefined, so a failure stops the chain with the offending
|
|
309
|
+
* form's own error messages already on screen.
|
|
310
|
+
*/
|
|
311
|
+
const onPay = async (): Promise<void> => {
|
|
312
|
+
if (action === undefined) {
|
|
313
|
+
alert(t('routes_order.step5.choose_payment'));
|
|
314
|
+
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (addonWhatsapp && addonWhatsappNumber.trim().length < 5) {
|
|
319
|
+
alert(t('routes_order.step5.addons.whatsapp.phone_error'));
|
|
320
|
+
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let person: PersonData | undefined;
|
|
325
|
+
|
|
326
|
+
await passenger.handleSubmit((data) => { person = data; })();
|
|
327
|
+
|
|
328
|
+
if (person === undefined || !checkAge(step1, person, t)) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/*
|
|
333
|
+
* Edited: Claude - Date: 2026-08-21
|
|
334
|
+
* The billing form is rendered for a guest (all of it) and for a
|
|
335
|
+
* customer whose profile has gaps (only those) - so it is validated
|
|
336
|
+
* whenever it was drawn, and skipped when it was not. It used to be
|
|
337
|
+
* skipped for every signed-in buyer, which is the assumption that let
|
|
338
|
+
* an incomplete profile reach the API with nothing to fill three NOT
|
|
339
|
+
* NULL columns with.
|
|
340
|
+
*/
|
|
341
|
+
let details = {} as BillingData;
|
|
342
|
+
|
|
343
|
+
if (askBilling) {
|
|
344
|
+
let collected: BillingData | undefined;
|
|
345
|
+
|
|
346
|
+
await billing.handleSubmit((data) => { collected = data; })();
|
|
347
|
+
|
|
348
|
+
if (collected === undefined) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
details = collected;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
OrderSend({ billing: details, person }, {
|
|
356
|
+
onSuccess: (data) => {
|
|
357
|
+
if (data === undefined) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const hash = step1.type === 'return' ? data.return : data.departure;
|
|
362
|
+
|
|
363
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
364
|
+
//
|
|
365
|
+
// Remembered here because the confirmation page cannot work it out.
|
|
366
|
+
// A card payment leaves the site for the bank and returns on a URL
|
|
367
|
+
// carrying nothing but this hash, and most buyers are guests with no
|
|
368
|
+
// account the order could be read back from. Keyed on the SAME hash
|
|
369
|
+
// the confirmation is reached with, so it cannot be attributed to a
|
|
370
|
+
// different order.
|
|
371
|
+
stash(
|
|
372
|
+
String(hash),
|
|
373
|
+
grandTotal.value,
|
|
374
|
+
[commerceItem(step2), ...(step3 ? [commerceItem(step3)] : [])],
|
|
375
|
+
currency
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
navigate(`/orders/process/${ action }/${ hash }`);
|
|
379
|
+
},
|
|
380
|
+
/*
|
|
381
|
+
* Edited: Claude - Date: 2026-08-22 (audit finding BOOK-13-c)
|
|
382
|
+
*
|
|
383
|
+
* Surface why the booking was refused. Every 4xx from order creation -
|
|
384
|
+
* a seat taken while the form was open, the unpaid-hold limit, a route
|
|
385
|
+
* that just went unavailable - left the buyer on the filled form with
|
|
386
|
+
* no message, clicking Pay again. The API's own message says exactly
|
|
387
|
+
* what happened.
|
|
388
|
+
*/
|
|
389
|
+
onError: (submitError: unknown) => {
|
|
390
|
+
const message = (submitError as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
391
|
+
|
|
392
|
+
notifyError(message ?? t('routes_order.checkout.error', { ns: 'common', defaultValue: 'We could not complete your booking. Please try again.' }));
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
if (busFrom === undefined || busTo === undefined) {
|
|
398
|
+
return <Loading t={ t } />;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<>
|
|
403
|
+
<Title>
|
|
404
|
+
<RiShoppingCartLine />
|
|
405
|
+
{ t('routes_order.step5.title') }
|
|
406
|
+
</Title>
|
|
407
|
+
|
|
408
|
+
<Steps value={ 4 } />
|
|
409
|
+
|
|
410
|
+
<Results>
|
|
411
|
+
<Listing>
|
|
412
|
+
<Passengers
|
|
413
|
+
values={ values }
|
|
414
|
+
step1={ step1 }
|
|
415
|
+
fields={ fields }
|
|
416
|
+
busFrom={ busFrom }
|
|
417
|
+
busTo={ busTo }
|
|
418
|
+
saved={ saved }
|
|
419
|
+
savedMax={ SavedData?.max }
|
|
420
|
+
seatsOptional={ seatsOffer !== undefined }
|
|
421
|
+
seatsFee={ seatsOffer && seatsOffer.price > 0 ? `${ seatsOffer.price } ${ currency }` : undefined }
|
|
422
|
+
onPickedCount={ setSeatsPicked }
|
|
423
|
+
t={ t }
|
|
424
|
+
errors={ passenger.formState.errors }
|
|
425
|
+
refs={ passenger.register }
|
|
426
|
+
onUpdate={ onUpdate }
|
|
427
|
+
/>
|
|
428
|
+
|
|
429
|
+
<Addons
|
|
430
|
+
data={ SettingsData?.addons }
|
|
431
|
+
flexOffer={ FlexData }
|
|
432
|
+
currency={ currency }
|
|
433
|
+
whatsapp={ addonWhatsapp }
|
|
434
|
+
whatsappNumber={ addonWhatsappNumber }
|
|
435
|
+
telegram={ addonTelegram }
|
|
436
|
+
sms={ addonSms }
|
|
437
|
+
smsNumber={ addonSmsNumber }
|
|
438
|
+
flex={ addonFlex }
|
|
439
|
+
t={ t }
|
|
440
|
+
onChangeWhatsapp={ setAddonWhatsapp }
|
|
441
|
+
onChangeWhatsappNumber={ setAddonWhatsappNumber }
|
|
442
|
+
onChangeTelegram={ setAddonTelegram }
|
|
443
|
+
onChangeSms={ setAddonSms }
|
|
444
|
+
onChangeSmsNumber={ setAddonSmsNumber }
|
|
445
|
+
onChangeFlex={ (value) => {
|
|
446
|
+
setAddonFlex(value);
|
|
447
|
+
|
|
448
|
+
// The attach rate this records is the one input the Flexible
|
|
449
|
+
// Ticket pricing decision is waiting on - it cannot be banded
|
|
450
|
+
// by window without knowing how often each is actually bought.
|
|
451
|
+
commerceAddon(value, 'Flexible Ticket', FlexData?.price ?? 0, currency);
|
|
452
|
+
} }
|
|
453
|
+
/>
|
|
454
|
+
|
|
455
|
+
{ askBilling && (
|
|
456
|
+
<Billing t={ t } errors={ billing.formState.errors } refs={ billing.register } only={ gaps } />
|
|
457
|
+
) }
|
|
458
|
+
</Listing>
|
|
459
|
+
|
|
460
|
+
<Sidebar
|
|
461
|
+
step1={ step1 }
|
|
462
|
+
step2={ step2 }
|
|
463
|
+
step3={ step3 }
|
|
464
|
+
/* Claude - 2026-08-22 (audit finding BOOK-09-b): the GROSS
|
|
465
|
+
fare, not the coupon-adjusted `total` - with a coupon the
|
|
466
|
+
summary used to read "TICKET 40 / DISCOUNT -5 / TOTAL 40",
|
|
467
|
+
arithmetic that does not add up on screen. Gross minus the
|
|
468
|
+
discount line now equals the total the buyer pays. */
|
|
469
|
+
fare={ getTotal(step2, step3).display }
|
|
470
|
+
addons={ addonItems.map(item => ({ label: item.label, value: `${ item.price } ${ currency }` })) }
|
|
471
|
+
coupon={ coupon }
|
|
472
|
+
total={ grandTotal }
|
|
473
|
+
action={ action }
|
|
474
|
+
single={ singleMethod }
|
|
475
|
+
pending={ isPending }
|
|
476
|
+
t={ t }
|
|
477
|
+
onPay={ onPay }
|
|
478
|
+
payments={ (
|
|
479
|
+
<Payments
|
|
480
|
+
selected={ action ?? '' }
|
|
481
|
+
isPartner={ isPartner ?? false }
|
|
482
|
+
routeId={ step2.id }
|
|
483
|
+
t={ t }
|
|
484
|
+
onChange={ onAction }
|
|
485
|
+
/>
|
|
486
|
+
) }
|
|
487
|
+
>
|
|
488
|
+
<Coupons current={ coupon } step2={ step2 } total={ total } t={ t } onApplied={ onApplyCoupon } />
|
|
489
|
+
</Sidebar>
|
|
490
|
+
</Results>
|
|
491
|
+
|
|
492
|
+
{ isPartner === undefined && (
|
|
493
|
+
<Actions>
|
|
494
|
+
<Button
|
|
495
|
+
type="button"
|
|
496
|
+
subtype="secondary"
|
|
497
|
+
text={ <><AiOutlineCaretLeft /> { t('routes_order.step4.back') }</> }
|
|
498
|
+
noMargin
|
|
499
|
+
onClick={ () => {
|
|
500
|
+
// keep whatever has been typed, even though it has not been
|
|
501
|
+
// validated - coming back to a half-filled form is far better
|
|
502
|
+
// than coming back to an empty one
|
|
503
|
+
onStore(passenger.getValues());
|
|
504
|
+
|
|
505
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
506
|
+
// `type`, not `_return`. On the 9-segment search URL a one-way
|
|
507
|
+
// trip carries the literal segment "none" as its return date,
|
|
508
|
+
// which is a truthy string - so this always chose the
|
|
509
|
+
// return-leg step, even for a booking that has no return leg.
|
|
510
|
+
onBack(step1.type === 'return' ? 3 : 2);
|
|
511
|
+
} }
|
|
512
|
+
/>
|
|
513
|
+
</Actions>
|
|
514
|
+
) }
|
|
515
|
+
</>
|
|
516
|
+
);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
export default Checkout;
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
single?: boolean
|
|
30
|
+
pending: boolean
|
|
31
|
+
t: TFunction<'common'>
|
|
32
|
+
onPay: () => void
|
|
33
|
+
/** coupon form, rendered directly above the totals it affects */
|
|
34
|
+
children?: React.ReactNode
|
|
35
|
+
/** payment-method picker, rendered directly above the button that uses it */
|
|
36
|
+
payments?: React.ReactNode
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The money, the trip, and the button that commits to both.
|
|
41
|
+
*
|
|
42
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
43
|
+
*
|
|
44
|
+
* Everything a buyer needs to check before paying, kept beside the form
|
|
45
|
+
* rather than above it. Previously the total lived at the top of a long
|
|
46
|
+
* page and scrolled away while add-ons were being ticked - so the number
|
|
47
|
+
* changed out of sight of the person it was changing for.
|
|
48
|
+
*/
|
|
49
|
+
const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, action, single, pending, t, onPay, children, payments }: Props): JSX.Element => (
|
|
50
|
+
<Container>
|
|
51
|
+
<Panel className="box">
|
|
52
|
+
<SubTitle>
|
|
53
|
+
<AiOutlineInfoCircle />
|
|
54
|
+
{ t('routes_order.step5.summary.title') }
|
|
55
|
+
</SubTitle>
|
|
56
|
+
|
|
57
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-06
|
|
58
|
+
Numbered only on a ROUND TRIP. This is the one place that knows
|
|
59
|
+
whether a second leg exists, so it is the one place that can
|
|
60
|
+
decide - a one-way labelled "Trip 1" would send the reader looking
|
|
61
|
+
for a Trip 2 that was never bought. */ }
|
|
62
|
+
<Information>
|
|
63
|
+
<About type="departure" step1={ step1 } data={ step2 } number={ step3 ? 1 : undefined } t={ t } />
|
|
64
|
+
<About type="return" step1={ step1 } data={ step3 } number={ step3 ? 2 : undefined } t={ t } />
|
|
65
|
+
</Information>
|
|
66
|
+
|
|
67
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
68
|
+
Coupon sits ABOVE the total, where its effect is visible the
|
|
69
|
+
moment it applies. In the main column it was several sections
|
|
70
|
+
away from the number it changes. */ }
|
|
71
|
+
{ children }
|
|
72
|
+
|
|
73
|
+
<div>
|
|
74
|
+
<Amount type="addon" label={ t('routes_order.step5.summary.fare') } value={ fare } t={ t } />
|
|
75
|
+
|
|
76
|
+
{ addons.map(item => (
|
|
77
|
+
<Amount key={ item.label } type="addon" label={ item.label } value={ item.value } t={ t } />
|
|
78
|
+
)) }
|
|
79
|
+
|
|
80
|
+
{ coupon && <Amount type="discount" code={ coupon.code } value={ coupon.discount } t={ t } /> }
|
|
81
|
+
|
|
82
|
+
<Amount type="total" value={ total.display } t={ t } />
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<Actions>
|
|
86
|
+
{ /* the method picker belongs next to the button that acts on it,
|
|
87
|
+
not at the far end of a long form */ }
|
|
88
|
+
{ payments }
|
|
89
|
+
|
|
90
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
91
|
+
The label follows the chosen method, so the button says what it
|
|
92
|
+
is about to do - "Reserve" does not take money, and a button
|
|
93
|
+
reading "Make Payment" for it would be a lie.
|
|
94
|
+
|
|
95
|
+
Edited: Ferjolt Ozuni - Date: 2026-08-07
|
|
96
|
+
And when there is only one method, the picker above renders
|
|
97
|
+
nothing, so this is the whole payment step. "Make Payment" reads
|
|
98
|
+
as "proceed to the part where you pay"; with nothing left in
|
|
99
|
+
between, it should say what actually happens next. */ }
|
|
100
|
+
<Button
|
|
101
|
+
loading={ pending }
|
|
102
|
+
noMargin
|
|
103
|
+
text={ (
|
|
104
|
+
<>
|
|
105
|
+
<MdDone />
|
|
106
|
+
{ action === 'reserve'
|
|
107
|
+
? t('routes_order.step5.reserve')
|
|
108
|
+
: single
|
|
109
|
+
? t('routes_order.step5.pay_now')
|
|
110
|
+
: t('routes_order.step5.next') }
|
|
111
|
+
</>
|
|
112
|
+
) }
|
|
113
|
+
onClick={ onPay }
|
|
114
|
+
/>
|
|
115
|
+
|
|
116
|
+
{ action === undefined && (
|
|
117
|
+
<Notice>{ t('routes_order.step5.choose_payment') }</Notice>
|
|
118
|
+
) }
|
|
119
|
+
</Actions>
|
|
120
|
+
</Panel>
|
|
121
|
+
</Container>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
export default Sidebar;
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
16
|
+
*
|
|
17
|
+
* 5/12 of the row rather than a fixed 340px, so on a wide screen the
|
|
18
|
+
* summary grows with the page instead of staying a narrow strip beside a
|
|
19
|
+
* very wide form. A proportion also keeps the payment options on one
|
|
20
|
+
* line at every width above the breakpoint.
|
|
21
|
+
*
|
|
22
|
+
* 1280 matches Results in ../../styles.ts, where the row layout starts.
|
|
23
|
+
*/
|
|
24
|
+
@media (min-width: 1280px) {
|
|
25
|
+
flex: 0 0 calc((5 / 12) * 100% - 20px);
|
|
26
|
+
position: sticky;
|
|
27
|
+
top: 15px;
|
|
28
|
+
max-height: calc(100vh - 30px);
|
|
29
|
+
overflow-y: auto;
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
export const Panel = styled.div`
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
gap: 12px;
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
export const Actions = styled.div`
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
gap: 10px;
|
|
43
|
+
padding-top: 12px;
|
|
44
|
+
border-top: 1px solid ${ props => props.theme.background.neutral };
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
export const Notice = styled.p`
|
|
48
|
+
margin: 0;
|
|
49
|
+
font-size: ${ props => props.theme.size.xs };
|
|
50
|
+
color: ${ props => props.theme.font.faded };
|
|
51
|
+
text-align: center;
|
|
52
|
+
`;
|