@autobusal/routes-order 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +673 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +59 -5
  14. package/Found/Dates/styles.ts +137 -7
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +10 -7
  18. package/Found/Filters/styles.ts +79 -8
  19. package/Found/Found.tsx +128 -29
  20. package/Found/Group/Group.tsx +3 -1
  21. package/Found/Header/styles.ts +20 -4
  22. package/Found/Orders/Orders.tsx +25 -4
  23. package/Found/Picks/Picks.tsx +10 -3
  24. package/Found/Picks/styles.ts +60 -9
  25. package/Found/Route/Route.tsx +189 -33
  26. package/Found/Route/styles.ts +488 -50
  27. package/Found/SameDay/SameDay.tsx +35 -0
  28. package/Found/SameDay/styles.ts +27 -0
  29. package/Found/Sort/styles.ts +52 -7
  30. package/Found/Summary/Summary.tsx +1 -1
  31. package/Found/Summary/styles.ts +12 -0
  32. package/Found/Watch/Watch.tsx +187 -0
  33. package/Found/Watch/styles.ts +118 -0
  34. package/Found/refine.ts +69 -0
  35. package/Links/Links.tsx +91 -0
  36. package/Links/styles.ts +75 -0
  37. package/RoutesOrder.tsx +239 -31
  38. package/Schedule/Schedule.tsx +123 -0
  39. package/Schedule/styles.ts +71 -0
  40. package/Sections/Schema.tsx +186 -0
  41. package/Sections/Sections.tsx +160 -0
  42. package/Sections/styles.ts +44 -0
  43. package/Step1/prepare.ts +21 -2
  44. package/Step2.tsx +34 -1
  45. package/Step4/Passenger/Passenger.tsx +253 -24
  46. package/Step4/Passenger/styles.ts +21 -1
  47. package/Step4/Passengers/Display.tsx +10 -2
  48. package/Step4/Passengers/Passengers.tsx +53 -4
  49. package/Step4/Saved/Choose.tsx +99 -0
  50. package/Step4/Saved/Remember.tsx +58 -0
  51. package/Step4/Saved/styles.ts +87 -0
  52. package/Step4/checkAge.ts +15 -4
  53. package/Step5/Addons/Addons.tsx +146 -4
  54. package/Step5/Addons/styles.ts +94 -0
  55. package/Step5/Billing/Billing.tsx +138 -70
  56. package/Step5/Coupons/Coupons.tsx +28 -18
  57. package/Step5/Coupons/styles.ts +41 -8
  58. package/Step5/Payments/Payments.tsx +36 -5
  59. package/Step5/Payments/styles.ts +16 -7
  60. package/Step5/Summary/About/About.tsx +99 -27
  61. package/Step5/Summary/About/styles.ts +118 -1
  62. package/Step5/Summary/styles.ts +12 -5
  63. package/Step5/utilities.ts +67 -12
  64. package/Steps/Steps.tsx +3 -1
  65. package/commerce.ts +24 -0
  66. package/package.json +1 -1
  67. package/services.ts +295 -12
  68. package/styles.ts +126 -0
  69. package/types.ts +78 -1
  70. package/Found/Booking/Booking.tsx +0 -61
  71. package/Found/Booking/styles.ts +0 -50
  72. package/Step4/Step4.tsx +0 -114
  73. package/Step5/Step5.tsx +0 -185
@@ -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
+ `;