@autobusal/routes-order 1.34.2 → 1.34.4
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 +16 -0
- package/Checkout/Checkout.tsx +143 -6
- package/Checkout/Sidebar/Sidebar.tsx +19 -1
- package/Found/Picks/Picks.tsx +8 -2
- package/Found/Route/Route.tsx +71 -1
- package/Found/Route/styles.ts +57 -0
- package/RoutesOrder.tsx +105 -7
- package/Step1/prepare.ts +23 -3
- package/Step4/Passenger/Passenger.tsx +42 -2
- package/Step4/Passengers/Display.tsx +4 -1
- package/Step4/Passengers/Passengers.tsx +6 -1
- package/Step5/Coupons/Coupons.tsx +30 -2
- package/Step5/Coupons/styles.ts +14 -0
- package/Step5/Payments/Payments.tsx +7 -1
- package/package.json +1 -1
- package/services.ts +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 1.34.4 - 2026-08-29
|
|
2
|
+
|
|
3
|
+
- The date strip and the search-again panel WRITE THE SEARCH INTO THE URL, so a refresh keeps the chosen day and a shared link opens the results it was copied from (audit A3-02f).
|
|
4
|
+
|
|
5
|
+
- A results URL carrying a real return date is read as a round trip whatever its type segment says - it used to sell a one-way silently (audit A3-07c).
|
|
6
|
+
|
|
7
|
+
- An agent's checkout previews what the card will actually be charged: the seller's own commission is stated as its own line and the surcharge is computed on the NET, the same base obtapi charges on. An agent was shown 67.05 against a 60.67 charge (audit A4-07).
|
|
8
|
+
|
|
9
|
+
- An invalid coupon says so, inline and announced - a code that did not exist reached no handler at all, so the commonest case (a typo) changed nothing on screen (audit A3-10c).
|
|
10
|
+
|
|
11
|
+
- The details expander gains the ITINERARY and a line on cancellation and changes; the quick-pick chips format the duration instead of printing a bare "04:30" that reads as a departure time (audit A3-02h, A3-02j).
|
|
12
|
+
|
|
13
|
+
## 1.34.3 - 2026-08-29
|
|
14
|
+
|
|
15
|
+
- Staff sellers (agent/subagent/operator/employee/admin) get an OPTIONAL passenger email box at checkout, so a counter-sold ticket reaches the passenger instead of only the seller and operator (audit A4-05, owner decision). obtapi already read {type}{i}_email/_notify_email; it now validates them too.
|
|
16
|
+
|
|
1
17
|
## 1.34.2 - 2026-08-29
|
|
2
18
|
|
|
3
19
|
- With more than one card gateway available, each Credit Card button carries its processor's name - they used to render byte-identical (audit A0-06).
|
package/Checkout/Checkout.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useState, useEffect } from 'react';
|
|
2
2
|
import { TFunction } from 'i18next';
|
|
3
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
4
|
import { useForm } from 'react-hook-form';
|
|
4
5
|
import { useNavigate } from 'react-router-dom';
|
|
5
6
|
import { RiShoppingCartLine } from 'react-icons/ri';
|
|
@@ -24,7 +25,7 @@ import { CouponData } from '@autobusal/providers/types/orders';
|
|
|
24
25
|
import { TotalData } from '../types';
|
|
25
26
|
import { useUserStore } from '@autobusal/providers/stores/user';
|
|
26
27
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
27
|
-
import { usePostOrder, useGetFlex, useGetPayments, useGetSavedPassengers } from '../services';
|
|
28
|
+
import { usePostOrder, useGetFlex, useGetPayments, useGetSavedPassengers, useGetComission } from '../services';
|
|
28
29
|
import { addon as commerceAddon, beginCheckout, stash } from '@autobusal/providers/Setup/commerce';
|
|
29
30
|
import { item as commerceItem } from '../commerce';
|
|
30
31
|
import { error as notifyError } from '@autobusal/utilities';
|
|
@@ -100,6 +101,10 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
100
101
|
const passenger = useForm<PersonData>();
|
|
101
102
|
const billing = useForm<BillingData>();
|
|
102
103
|
|
|
104
|
+
// Claude - 2026-08-29 (audit A3-08c): to re-ask for the seat map after a
|
|
105
|
+
// refusal, so a seat taken while this form was open stops looking free
|
|
106
|
+
const queryClient = useQueryClient();
|
|
107
|
+
|
|
103
108
|
const [ coupon, setCoupon ] = useState<CouponData | undefined>(undefined);
|
|
104
109
|
const [ total, setTotal ] = useState<TotalData>(getTotal(step2, step3));
|
|
105
110
|
const [ action, setAction ] = useState<string | undefined>(undefined);
|
|
@@ -179,6 +184,61 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
179
184
|
const busFrom = useBusOccupied(step1.departure, step2.id as number);
|
|
180
185
|
const busTo = useBusOccupied(step1._return, step3?.id as (number | undefined));
|
|
181
186
|
|
|
187
|
+
/*
|
|
188
|
+
* Claude - 2026-08-29 (audit A3-08c, low)
|
|
189
|
+
*
|
|
190
|
+
* A SEAT SOMEBODY ELSE HAS TAKEN STOPS BEING "YOURS".
|
|
191
|
+
*
|
|
192
|
+
* The chosen-seat badge was pure local state in ChooseSeat, so when a
|
|
193
|
+
* booking was refused because the seat had gone, the button went on
|
|
194
|
+
* reading "You have chosen seat 7" until the buyer happened to reopen the
|
|
195
|
+
* map - and pressing Pay again simply failed again for the same reason.
|
|
196
|
+
*
|
|
197
|
+
* Driven by the OCCUPANCY rather than by the error message: the reply is a
|
|
198
|
+
* localised sentence in one of fifteen languages and matching on its text
|
|
199
|
+
* would be a guess, while "is this seat taken now" is a fact and answers
|
|
200
|
+
* the case where a seat sells while the buyer is still filling the form.
|
|
201
|
+
*
|
|
202
|
+
* `reset` is a remount key for the passenger block, which is what makes
|
|
203
|
+
* ChooseSeat re-read its default - it owns `selected` internally and has
|
|
204
|
+
* no other way to be told. The typed passenger details survive: they live
|
|
205
|
+
* in the react-hook-form state outside this block, not in the inputs.
|
|
206
|
+
*/
|
|
207
|
+
const [ seatsReset, setSeatsReset ] = useState<number>(0);
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const values = passenger.getValues() as Record<string, unknown>;
|
|
211
|
+
|
|
212
|
+
const gone = Object.keys(values).filter(field => {
|
|
213
|
+
const leg = /_s([dr])$/.exec(field);
|
|
214
|
+
|
|
215
|
+
if (!leg) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const seat = Number(values[field]);
|
|
220
|
+
|
|
221
|
+
const taken = leg[1] === 'd' ? busFrom?.occupied : busTo?.occupied;
|
|
222
|
+
|
|
223
|
+
return seat > 0 && Array.isArray(taken) && taken.includes(seat);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (gone.length === 0) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
gone.forEach(field => passenger.setValue(field, 0));
|
|
231
|
+
|
|
232
|
+
// upstream too - it is what ChooseSeat reads as its default on remount
|
|
233
|
+
onStore(passenger.getValues());
|
|
234
|
+
|
|
235
|
+
// the fee line counts what is still hand-picked, which is now fewer
|
|
236
|
+
setSeatsPicked(count => Math.max(0, count - gone.length));
|
|
237
|
+
|
|
238
|
+
setSeatsReset(value => value + 1);
|
|
239
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
+
}, [busFrom, busTo]);
|
|
241
|
+
|
|
182
242
|
// Edited: Claude - Date: 2026-08-28 (sweep, low): the currency is the
|
|
183
243
|
// LAST token - split(' ')[1] read '250.00' out of '1 250.00 EUR'
|
|
184
244
|
const currency = currencyOf(total.display);
|
|
@@ -276,7 +336,48 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
276
336
|
* reserve and complimentary - see surchargeOf, which only recognises the
|
|
277
337
|
* five gateways obtapi actually surcharges.
|
|
278
338
|
*/
|
|
279
|
-
|
|
339
|
+
/*
|
|
340
|
+
* Claude - 2026-08-29 (audit A4-07, medium)
|
|
341
|
+
*
|
|
342
|
+
* WHAT A RESELLER ACTUALLY OWES. An agency pays the fare minus its own
|
|
343
|
+
* commission (obtapi Payments\Owed), the card is charged on that net, and
|
|
344
|
+
* this screen knew nothing about it - so an agent was shown a 1.95
|
|
345
|
+
* surcharge on a 65.10 basket and a 67.05 total, while RaiAccept asked for
|
|
346
|
+
* 60.67. Every figure a reseller saw before pressing pay was wrong, in a
|
|
347
|
+
* way no reconciliation of their card statement could explain.
|
|
348
|
+
*
|
|
349
|
+
* Zero for everybody else, and no request made for them: a passenger earns
|
|
350
|
+
* no commission, so net and gross are the same number and their checkout
|
|
351
|
+
* is untouched.
|
|
352
|
+
*/
|
|
353
|
+
const { data: ComissionData } = useGetComission(
|
|
354
|
+
UserData !== undefined && ['agent', 'subagent'].includes(UserData.type),
|
|
355
|
+
step1.departure,
|
|
356
|
+
step2.price.id,
|
|
357
|
+
step1.adults,
|
|
358
|
+
step1.children,
|
|
359
|
+
step1.babies,
|
|
360
|
+
step3 !== undefined ? step1._return : undefined,
|
|
361
|
+
step3?.price.id
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
/*
|
|
365
|
+
* Never more than the basket - a commission larger than the fare is a
|
|
366
|
+
* configuration mistake, and the server's own Owed::forBooking floors it at
|
|
367
|
+
* zero rather than paying anybody to travel. The two must agree or the
|
|
368
|
+
* total shown is wrong again, in the other direction.
|
|
369
|
+
*/
|
|
370
|
+
const comission = Math.min(
|
|
371
|
+
Math.round(((ComissionData?.comission_agent ?? 0) + Number.EPSILON) * 100) / 100,
|
|
372
|
+
grandTotal.value
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
/*
|
|
376
|
+
* Claude - 2026-08-29: the surcharge is charged on what is OWED, which is
|
|
377
|
+
* the basket less the seller's own commission - the same base
|
|
378
|
+
* Payments\Surcharge::quote uses. It was computed on the gross.
|
|
379
|
+
*/
|
|
380
|
+
const surcharge = surchargeOf(grandTotal.value - comission, action, paymentsData);
|
|
280
381
|
|
|
281
382
|
/*
|
|
282
383
|
* What the buyer is actually asked for. Rounded rather than summed
|
|
@@ -288,9 +389,11 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
288
389
|
* value of the SALE is the tickets and the add-ons, and folding a card
|
|
289
390
|
* fee into it would quietly inflate revenue for anyone paying by card.
|
|
290
391
|
*/
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
392
|
+
const owed = Math.round((grandTotal.value - comission + surcharge) * 100) / 100;
|
|
393
|
+
|
|
394
|
+
const payable: TotalData = (surcharge > 0 || comission > 0) ? {
|
|
395
|
+
display: `${ owed } ${ currency }`,
|
|
396
|
+
value: owed
|
|
294
397
|
} : grandTotal;
|
|
295
398
|
|
|
296
399
|
/**
|
|
@@ -419,6 +522,22 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
419
522
|
const message = (submitError as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
420
523
|
|
|
421
524
|
notifyError(message ?? t('routes_order.checkout.error', { ns: 'common', defaultValue: 'We could not complete your booking. Please try again.' }));
|
|
525
|
+
|
|
526
|
+
/*
|
|
527
|
+
* Claude - 2026-08-29 (audit A3-08c, low)
|
|
528
|
+
*
|
|
529
|
+
* REFRESH THE SEAT MAP after a refusal. The commonest reason a
|
|
530
|
+
* booking is refused at this point is that somebody else took a seat
|
|
531
|
+
* while this form was open - and the map the buyer is looking at was
|
|
532
|
+
* fetched before that happened, so it still shows the seat as free
|
|
533
|
+
* and the button still reads "You have chosen seat 7". Re-asking is
|
|
534
|
+
* cheap, and it is what makes the effect below able to notice.
|
|
535
|
+
*
|
|
536
|
+
* Unconditional rather than gated on reading the message: the reply
|
|
537
|
+
* is a localised sentence in one of fifteen languages, and a stale
|
|
538
|
+
* map is worth re-fetching whatever went wrong.
|
|
539
|
+
*/
|
|
540
|
+
queryClient.invalidateQueries({ queryKey: ['get-bus-occupied'] });
|
|
422
541
|
}
|
|
423
542
|
});
|
|
424
543
|
};
|
|
@@ -439,9 +558,21 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
439
558
|
<Results>
|
|
440
559
|
<Listing>
|
|
441
560
|
<Passengers
|
|
561
|
+
/* Claude - 2026-08-29 (audit A3-08c): remounted when a chosen
|
|
562
|
+
seat turns out to be taken - the only way ChooseSeat, which
|
|
563
|
+
owns its selection internally, can be told to re-read it. */
|
|
564
|
+
key={ seatsReset }
|
|
442
565
|
values={ values }
|
|
443
566
|
step1={ step1 }
|
|
444
567
|
fields={ fields }
|
|
568
|
+
/*
|
|
569
|
+
* Claude - 2026-08-29 (audit A4-05, owner decision): only staff
|
|
570
|
+
* are asked. They are the sellers with no billing block - see
|
|
571
|
+
* `gaps` above - so this optional box is the only way a
|
|
572
|
+
* counter-sold ticket reaches the passenger directly instead of
|
|
573
|
+
* landing solely with the agent and the operator.
|
|
574
|
+
*/
|
|
575
|
+
askEmail={ UserData !== undefined && ['agent', 'subagent', 'operator', 'employee', 'admin'].includes(UserData.type) }
|
|
445
576
|
busFrom={ busFrom }
|
|
446
577
|
busTo={ busTo }
|
|
447
578
|
saved={ saved }
|
|
@@ -500,6 +631,10 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
500
631
|
coupon={ coupon }
|
|
501
632
|
total={ payable }
|
|
502
633
|
surcharge={ surcharge > 0 ? `${ surcharge } ${ currency }` : undefined }
|
|
634
|
+
/* the seller's own cut, taken off what they settle - the same line
|
|
635
|
+
their invoice carries (audit A4-17), so the two documents for one
|
|
636
|
+
sale finally state the same number */
|
|
637
|
+
comission={ comission > 0 ? `${ comission } ${ currency }` : undefined }
|
|
503
638
|
action={ action }
|
|
504
639
|
single={ singleMethod }
|
|
505
640
|
pending={ isPending }
|
|
@@ -510,7 +645,9 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
510
645
|
selected={ action ?? '' }
|
|
511
646
|
isPartner={ isPartner ?? false }
|
|
512
647
|
routeId={ step2.id }
|
|
513
|
-
|
|
648
|
+
/* what the payer OWES, so each button's "+X" matches the
|
|
649
|
+
surcharge line under it and the charge itself (audit A4-07) */
|
|
650
|
+
total={ Math.round((grandTotal.value - comission) * 100) / 100 }
|
|
514
651
|
currency={ currency }
|
|
515
652
|
t={ t }
|
|
516
653
|
onChange={ onAction }
|
|
@@ -34,6 +34,16 @@ interface Props {
|
|
|
34
34
|
* what changed when they picked a card.
|
|
35
35
|
*/
|
|
36
36
|
surcharge?: string
|
|
37
|
+
/**
|
|
38
|
+
* The seller's own commission, already deducted from `total`.
|
|
39
|
+
*
|
|
40
|
+
* Claude - 2026-08-29 (audit A4-07): a reseller settles the NET, so the
|
|
41
|
+
* deduction is stated rather than folded silently into the total - the
|
|
42
|
+
* same shape their invoice takes, and for the same reason: the body has to
|
|
43
|
+
* add up to the number their card is charged. Undefined for anybody who
|
|
44
|
+
* earns no commission, which is every passenger.
|
|
45
|
+
*/
|
|
46
|
+
comission?: string
|
|
37
47
|
action?: string
|
|
38
48
|
single?: boolean
|
|
39
49
|
pending: boolean
|
|
@@ -55,7 +65,7 @@ interface Props {
|
|
|
55
65
|
* page and scrolled away while add-ons were being ticked - so the number
|
|
56
66
|
* changed out of sight of the person it was changing for.
|
|
57
67
|
*/
|
|
58
|
-
const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, surcharge, action, single, pending, t, onPay, children, payments }: Props): JSX.Element => (
|
|
68
|
+
const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, surcharge, comission, action, single, pending, t, onPay, children, payments }: Props): JSX.Element => (
|
|
59
69
|
<Container>
|
|
60
70
|
<Panel className="box">
|
|
61
71
|
<SubTitle>
|
|
@@ -88,6 +98,14 @@ const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, surcharge,
|
|
|
88
98
|
|
|
89
99
|
{ coupon && <Amount type="discount" code={ coupon.code } value={ coupon.discount } t={ t } /> }
|
|
90
100
|
|
|
101
|
+
{ /* Claude - 2026-08-29 (audit A4-07): above the surcharge, because
|
|
102
|
+
the surcharge is charged on what is left after it - the lines
|
|
103
|
+
then read in the order the arithmetic happens. `discount` styling
|
|
104
|
+
so it reads as money coming OFF, which it is. */ }
|
|
105
|
+
{ comission && (
|
|
106
|
+
<Amount type="discount" label={ t('routes_order.step5.summary.comission') } value={ comission } t={ t } />
|
|
107
|
+
) }
|
|
108
|
+
|
|
91
109
|
{ /* Claude - 2026-08-29: below the discount and above the total,
|
|
92
110
|
because it is the last thing added to what is owed - and it is
|
|
93
111
|
the only line here that appears and disappears as the payment
|
package/Found/Picks/Picks.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import { Container, Pick, Label, Value, Meta, Company } from './styles';
|
|
3
|
-
import { SortKey, sortItems } from '../refine';
|
|
3
|
+
import { SortKey, sortItems, formatDuration } from '../refine';
|
|
4
4
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
@@ -55,7 +55,13 @@ const Picks = ({ items, sort, t, onSelect }: Props): JSX.Element | null => {
|
|
|
55
55
|
<Value>{ best.price.display }</Value>
|
|
56
56
|
|
|
57
57
|
<Meta>
|
|
58
|
-
{
|
|
58
|
+
{ /* Claude - 2026-08-29 (audit A3-02j, low): the duration
|
|
59
|
+
FORMATTED, not the raw "HH:MM" the API sends. On a card
|
|
60
|
+
whose other line is a price, "04:30" reads as a departure
|
|
61
|
+
time - and the result cards below already went through
|
|
62
|
+
exactly this (the 2026-08-01 note in Route.tsx: "the clock
|
|
63
|
+
is the point"). Same helper, so the two cannot diverge. */ }
|
|
64
|
+
{ best.duration !== '-' && formatDuration(best.duration, t) }
|
|
59
65
|
|
|
60
66
|
{ /* The operator's name is hidden on a phone - see Company in
|
|
61
67
|
styles. The separator goes with it, or a card reads
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -6,8 +6,9 @@ import { RiBus2Line } from 'react-icons/ri';
|
|
|
6
6
|
import { MdOutlineAltRoute } from 'react-icons/md';
|
|
7
7
|
import { formatDuration, splitPrice } from '../refine';
|
|
8
8
|
import { Button, RouteFeature, Information, Rating } from '@autobusal/common';
|
|
9
|
+
import { useGetSettings } from '@autobusal/providers/services';
|
|
9
10
|
import { createDate, getDayParts } from '@autobusal/utilities';
|
|
10
|
-
import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay } from './styles';
|
|
11
|
+
import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay, Stops, StopsItems, StopRow, StopTime, StopDay, StopBorder } from './styles';
|
|
11
12
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
12
13
|
|
|
13
14
|
// neutral placeholder so a missing/broken operator logo doesn't render the
|
|
@@ -45,6 +46,21 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
|
|
|
45
46
|
const customs = Object.values(data.customs).join(', ');
|
|
46
47
|
const transiting = Object.values(data.transiting).join(', ');
|
|
47
48
|
|
|
49
|
+
/*
|
|
50
|
+
* Claude - 2026-08-29 (audit A3-02h, low)
|
|
51
|
+
*
|
|
52
|
+
* The itinerary and whether this ticket can be changed - the two things
|
|
53
|
+
* the details expander was missing. `stops` is absent on an
|
|
54
|
+
* external-provider offer and on an older obtapi, which is why it is
|
|
55
|
+
* defaulted rather than assumed; a list of one is a leg with no journey
|
|
56
|
+
* in it and draws nothing.
|
|
57
|
+
*/
|
|
58
|
+
const stops = data.stops ?? [];
|
|
59
|
+
|
|
60
|
+
const { data: settings } = useGetSettings();
|
|
61
|
+
|
|
62
|
+
const flexible = settings.addons?.flex?.available === true;
|
|
63
|
+
|
|
48
64
|
const price = splitPrice(data.price.display);
|
|
49
65
|
|
|
50
66
|
const features = data.trip.features_data.map((item, index) => (
|
|
@@ -299,6 +315,60 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
|
|
|
299
315
|
</ButtonPolicy>
|
|
300
316
|
</Detail>
|
|
301
317
|
) }
|
|
318
|
+
|
|
319
|
+
{ /* Claude - 2026-08-29 (audit A3-02h, low): WHETHER THE TICKET CAN
|
|
320
|
+
BE CHANGED, said where the trip is chosen rather than three
|
|
321
|
+
screens later. Only when the brand actually sells the option -
|
|
322
|
+
on a brand without it the old deadline model applies and its
|
|
323
|
+
terms belong to the route's own information document, linked
|
|
324
|
+
beside this. */ }
|
|
325
|
+
{ !data.external && flexible && (
|
|
326
|
+
<Detail>
|
|
327
|
+
<TitleDetail>{ t('routes_order.step2.route.changes.title') }</TitleDetail>
|
|
328
|
+
{ t('routes_order.step2.route.changes.flex') }
|
|
329
|
+
</Detail>
|
|
330
|
+
) }
|
|
331
|
+
|
|
332
|
+
{ /* Claude - 2026-08-29 (audit A3-02h, low): THE ITINERARY. The card
|
|
333
|
+
has counted the intermediate stops since August without ever
|
|
334
|
+
being able to name them - and where a coach calls is one of the
|
|
335
|
+
few things a traveller actually chooses between when two
|
|
336
|
+
operators run the same corridor. Last, and full width, because
|
|
337
|
+
it is the only list here. */ }
|
|
338
|
+
{ stops.length > 1 && (
|
|
339
|
+
<Stops>
|
|
340
|
+
<TitleDetail>{ t('routes_order.step2.route.stops.title') }</TitleDetail>
|
|
341
|
+
|
|
342
|
+
<StopsItems>
|
|
343
|
+
{ stops.map((stop, index) => {
|
|
344
|
+
const end = index === 0 || index === stops.length - 1;
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<StopRow key={ index } $end={ end }>
|
|
348
|
+
{ /* the boarding stop is announced by its DEPARTURE, every
|
|
349
|
+
later call by when the coach gets there */ }
|
|
350
|
+
<StopTime>{ index === 0 ? stop.departure : stop.arrival }</StopTime>
|
|
351
|
+
|
|
352
|
+
<span>
|
|
353
|
+
{ stop.city }
|
|
354
|
+
{ stop.stop ? ` · ${ stop.stop }` : '' }
|
|
355
|
+
</span>
|
|
356
|
+
|
|
357
|
+
{ stop.day > 0 && (
|
|
358
|
+
<StopDay title={ t('routes_order.step2.route.next_day', { count: stop.day }) }>
|
|
359
|
+
+{ stop.day }
|
|
360
|
+
</StopDay>
|
|
361
|
+
) }
|
|
362
|
+
|
|
363
|
+
{ stop.douane && (
|
|
364
|
+
<StopBorder>{ t('routes_order.step2.route.stops.border') }</StopBorder>
|
|
365
|
+
) }
|
|
366
|
+
</StopRow>
|
|
367
|
+
);
|
|
368
|
+
}) }
|
|
369
|
+
</StopsItems>
|
|
370
|
+
</Stops>
|
|
371
|
+
) }
|
|
302
372
|
</Details>
|
|
303
373
|
|
|
304
374
|
{ information && typeof data.id === 'number' && <Information id={ data.id } t={ t } onClose={ () => setInformation(false) } /> }
|
package/Found/Route/styles.ts
CHANGED
|
@@ -560,6 +560,63 @@ export const TitleDetail = styled.h6`
|
|
|
560
560
|
font-size: ${ props => props.theme.size.xs };
|
|
561
561
|
`;
|
|
562
562
|
|
|
563
|
+
/**
|
|
564
|
+
* The itinerary, as a list of calls.
|
|
565
|
+
*
|
|
566
|
+
* Claude - 2026-08-29 (audit A3-02h). A full-width detail rather than a
|
|
567
|
+
* column beside the others: it is the one item here that is a LIST, and
|
|
568
|
+
* squeezed into a third of the row every stop wrapped onto two lines.
|
|
569
|
+
*/
|
|
570
|
+
export const Stops = styled(Detail)`
|
|
571
|
+
@media (min-width: 480px) {
|
|
572
|
+
flex-basis: 100%;
|
|
573
|
+
}
|
|
574
|
+
@media (min-width: 768px) {
|
|
575
|
+
flex: 1 1 100%;
|
|
576
|
+
}
|
|
577
|
+
`;
|
|
578
|
+
|
|
579
|
+
export const StopsItems = styled.ol`
|
|
580
|
+
display: flex;
|
|
581
|
+
flex-direction: column;
|
|
582
|
+
gap: 3px;
|
|
583
|
+
margin: 0;
|
|
584
|
+
padding: 0;
|
|
585
|
+
list-style: none;
|
|
586
|
+
`;
|
|
587
|
+
|
|
588
|
+
export const StopRow = styled.li<{ $end: boolean }>`
|
|
589
|
+
display: flex;
|
|
590
|
+
align-items: baseline;
|
|
591
|
+
gap: 6px;
|
|
592
|
+
font-weight: ${ props => (props.$end ? 700 : 400) };
|
|
593
|
+
`;
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The clock time, in a fixed column so the stop names line up under one
|
|
597
|
+
* another however long the times are.
|
|
598
|
+
*/
|
|
599
|
+
export const StopTime = styled.span`
|
|
600
|
+
flex: 0 0 auto;
|
|
601
|
+
min-width: 42px;
|
|
602
|
+
color: ${ props => props.theme.font.faded };
|
|
603
|
+
font-variant-numeric: tabular-nums;
|
|
604
|
+
`;
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* "+1" against a stop the coach reaches after midnight - the same marker the
|
|
608
|
+
* arrival on the card above carries, for the same reason.
|
|
609
|
+
*/
|
|
610
|
+
export const StopDay = styled.span`
|
|
611
|
+
color: ${ props => props.theme.font.warning };
|
|
612
|
+
font-size: ${ props => props.theme.size.xs };
|
|
613
|
+
`;
|
|
614
|
+
|
|
615
|
+
export const StopBorder = styled.span`
|
|
616
|
+
color: ${ props => props.theme.font.info };
|
|
617
|
+
font-size: ${ props => props.theme.size.xs };
|
|
618
|
+
`;
|
|
619
|
+
|
|
563
620
|
export const ButtonPolicy = styled.button`
|
|
564
621
|
display: flex;
|
|
565
622
|
align-items: center;
|
package/RoutesOrder.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
-
import { useParams, useSearchParams } from 'react-router-dom';
|
|
2
|
+
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
|
3
3
|
import { TFunction } from 'i18next';
|
|
4
4
|
import { AiOutlineSearch, AiOutlineCaretDown, AiOutlineCaretUp } from 'react-icons/ai';
|
|
5
5
|
import { Meta } from '@autobusal/common';
|
|
@@ -89,6 +89,27 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
89
89
|
*/
|
|
90
90
|
const [ searchParams, setSearchParams ] = useSearchParams();
|
|
91
91
|
|
|
92
|
+
const navigate = useNavigate();
|
|
93
|
+
|
|
94
|
+
const location = useLocation();
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* Claude - 2026-08-29 (audit A3-02f)
|
|
98
|
+
*
|
|
99
|
+
* Where this wizard's own URLs start, taken from the path in hand rather
|
|
100
|
+
* than hardcoded - so whatever locale prefix is in front of /bus-lines
|
|
101
|
+
* survives being written back. Null when the segment is not there at all,
|
|
102
|
+
* which is the honest answer for a mounting that carries no search spec in
|
|
103
|
+
* its URL, and makes syncUrl a no-op rather than a wrong guess.
|
|
104
|
+
*/
|
|
105
|
+
const base = (() => {
|
|
106
|
+
const segments = location.pathname.split('/');
|
|
107
|
+
|
|
108
|
+
const at = segments.indexOf('bus-lines');
|
|
109
|
+
|
|
110
|
+
return at === -1 ? null : segments.slice(0, at + 1).join('/');
|
|
111
|
+
})();
|
|
112
|
+
|
|
92
113
|
const step = searchParams.get('step');
|
|
93
114
|
|
|
94
115
|
const current = (() => {
|
|
@@ -173,18 +194,95 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
173
194
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
195
|
}, [urlKey]);
|
|
175
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Write a search spec into the address bar, and adopt exactly what that
|
|
199
|
+
* URL would be read back as.
|
|
200
|
+
*
|
|
201
|
+
* Claude - 2026-08-29 (audit A3-02f, medium)
|
|
202
|
+
*
|
|
203
|
+
* THE DATE STRIP CHANGED THE RESULTS AND NOT THE URL. Clicking a day on
|
|
204
|
+
* the strip swapped the listing underneath while the address bar still
|
|
205
|
+
* named the day the visitor arrived on - so a refresh silently threw the
|
|
206
|
+
* chosen day away, and a shared or bookmarked link took the reader to a
|
|
207
|
+
* different date than the one on the screen it was copied from. The same
|
|
208
|
+
* was true of the collapsed search-again panel, which can change cities
|
|
209
|
+
* and passenger counts as well as dates.
|
|
210
|
+
*
|
|
211
|
+
* `prepare` is run over the params being written, and its answer - not the
|
|
212
|
+
* caller's spec - is what goes into state. That is what keeps the two from
|
|
213
|
+
* drifting: a one-way's return slot is normalised to "none" in the URL and
|
|
214
|
+
* back to the departure date in state, and if state kept the caller's
|
|
215
|
+
* value instead, the effect below would see a URL describing a different
|
|
216
|
+
* search and helpfully throw away the chosen coach.
|
|
217
|
+
*
|
|
218
|
+
* Replace, not push. Stepping through days is browsing one screen, not
|
|
219
|
+
* walking a trail - a strip of seven days should not cost seven presses of
|
|
220
|
+
* Back to leave the page.
|
|
221
|
+
*/
|
|
222
|
+
const syncUrl = (spec: RoutesSearchForm, dropStep = false): RoutesSearchForm => {
|
|
223
|
+
// No /bus-lines in the path means this wizard is mounted somewhere that
|
|
224
|
+
// does not carry a search spec in its URL, and there is nothing to write.
|
|
225
|
+
if (base === null) {
|
|
226
|
+
if (dropStep) {
|
|
227
|
+
goto(null, true);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return spec;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const query = new URLSearchParams(searchParams);
|
|
234
|
+
|
|
235
|
+
/*
|
|
236
|
+
* The step is KEPT unless the caller says otherwise. Changing the return
|
|
237
|
+
* date happens ON the return step, and deleting `step=return` here would
|
|
238
|
+
* have answered a date change by throwing the buyer back to the outbound
|
|
239
|
+
* results.
|
|
240
|
+
*/
|
|
241
|
+
if (dropStep) {
|
|
242
|
+
query.delete('step');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// the chosen stations travel in the query, and prepare reads them from
|
|
246
|
+
// there - so they have to be written before it runs
|
|
247
|
+
for (const [key, value] of [['sfrom', spec.stop_from], ['sto', spec.stop_to]] as const) {
|
|
248
|
+
if (value) {
|
|
249
|
+
query.set(key, String(value));
|
|
250
|
+
} else {
|
|
251
|
+
query.delete(key);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const written = {
|
|
256
|
+
from: spec.from,
|
|
257
|
+
to: spec.to,
|
|
258
|
+
departure: spec.departure.replace(/\//g, '-'),
|
|
259
|
+
// `_return` is optional on the form type, and "none" is the literal a
|
|
260
|
+
// one-way URL carries - so an absent one is a one-way, not a blank slot
|
|
261
|
+
_return: spec.type === 'return' && spec._return ? spec._return.replace(/\//g, '-') : 'none',
|
|
262
|
+
type: spec.type,
|
|
263
|
+
adults: String(spec.adults),
|
|
264
|
+
children: String(spec.children),
|
|
265
|
+
babies: String(spec.babies)
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const path = `${ base }/${ written.from }/${ written.to }/${ written.departure }/${ written._return }/${ written.type }/${ written.adults }/${ written.children }/${ written.babies }`;
|
|
269
|
+
|
|
270
|
+
const search = query.toString();
|
|
271
|
+
|
|
272
|
+
navigate(search ? `${ path }?${ search }` : path, { replace: true });
|
|
273
|
+
|
|
274
|
+
return prepare(undefined, written, query, settings.preferences.defaultLocations);
|
|
275
|
+
};
|
|
276
|
+
|
|
176
277
|
const onSearchDate = (type: ('departure' | '_return'), day: string): void => {
|
|
177
278
|
if (step1 !== undefined) {
|
|
178
|
-
setStep1({
|
|
179
|
-
...step1,
|
|
180
|
-
[type]: day
|
|
181
|
-
});
|
|
279
|
+
setStep1(syncUrl({ ...step1, [type]: day }));
|
|
182
280
|
}
|
|
183
281
|
};
|
|
184
282
|
|
|
185
283
|
const onStep1 = (data: RoutesSearchForm): void => {
|
|
186
|
-
|
|
187
|
-
|
|
284
|
+
// a new search lands on the results, so the step goes with the old one
|
|
285
|
+
setStep1(syncUrl(data, true));
|
|
188
286
|
|
|
189
287
|
// we mark it as redirected
|
|
190
288
|
setRedirected(true);
|
package/Step1/prepare.ts
CHANGED
|
@@ -12,8 +12,6 @@ const prepare = (
|
|
|
12
12
|
return value;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const type = params.type === 'return' ? 'return' : 'departure';
|
|
16
|
-
|
|
17
15
|
const from = params.from ?? defaultLocations.from;
|
|
18
16
|
/*
|
|
19
17
|
* Edited: Claude - Date: 2026-08-29
|
|
@@ -44,10 +42,32 @@ const prepare = (
|
|
|
44
42
|
*/
|
|
45
43
|
const requested = params._return?.replace(/-/g, '/');
|
|
46
44
|
|
|
47
|
-
const
|
|
45
|
+
const hasReturn = isPreparedDate(requested);
|
|
46
|
+
|
|
47
|
+
const _return = hasReturn
|
|
48
48
|
? String(requested)
|
|
49
49
|
: (isPreparedDate(departure) ? departure : futureDate(2));
|
|
50
50
|
|
|
51
|
+
/*
|
|
52
|
+
* Claude - 2026-08-29 (audit A3-07c, medium)
|
|
53
|
+
*
|
|
54
|
+
* A RETURN DATE IN THE URL MEANS A ROUND TRIP, whatever the type segment
|
|
55
|
+
* says. The two are independent slots on the 9-segment route and they can
|
|
56
|
+
* contradict each other - .../12-10-2026/19-10-2026/departure/... names a
|
|
57
|
+
* return date and then declares the trip one-way. That URL used to sell a
|
|
58
|
+
* ONE-WAY silently: the visitor saw their outbound date honoured, their
|
|
59
|
+
* return date apparently accepted, no return leg offered anywhere, and a
|
|
60
|
+
* single ticket at the end of it.
|
|
61
|
+
*
|
|
62
|
+
* A one-way URL carries the literal "none" in that slot (see below), so
|
|
63
|
+
* "not a date" is how one-way is really expressed and nothing that means
|
|
64
|
+
* one-way is affected. Resolving towards the round trip rather than
|
|
65
|
+
* dropping the date is the safe direction: the buyer who typed or shared
|
|
66
|
+
* two dates gets the trip they described and can still switch back in the
|
|
67
|
+
* form, where dropping it would quietly sell them half a journey.
|
|
68
|
+
*/
|
|
69
|
+
const type = params.type === 'return' || hasReturn ? 'return' : 'departure';
|
|
70
|
+
|
|
51
71
|
const adults = params.adults ?? 1;
|
|
52
72
|
const children = params.children ?? 0;
|
|
53
73
|
const babies = params.babies ?? 0;
|
|
@@ -15,6 +15,15 @@ interface Props {
|
|
|
15
15
|
number: number
|
|
16
16
|
values?: PersonData
|
|
17
17
|
fields: string[]
|
|
18
|
+
/**
|
|
19
|
+
* Claude - 2026-08-29 (audit A4-05, owner decision): staff selling at the
|
|
20
|
+
* counter get an OPTIONAL email box per passenger. They are the only
|
|
21
|
+
* sellers with no billing block (an agent or operator writes no
|
|
22
|
+
* orders_contact row), so without it a walk-in customer's ticket reached
|
|
23
|
+
* only the agent and the operator and had to be Re-Sent by hand. Optional
|
|
24
|
+
* on purpose: a counter sale often has no email to give.
|
|
25
|
+
*/
|
|
26
|
+
askEmail?: boolean
|
|
18
27
|
withReturn: boolean
|
|
19
28
|
busFrom: OccupiedData
|
|
20
29
|
busTo: OccupiedData
|
|
@@ -47,7 +56,7 @@ interface Props {
|
|
|
47
56
|
// telegram render ONLY when the route requires them - and are then
|
|
48
57
|
// required. Name and date of birth stay mandatory always. `fields` is the
|
|
49
58
|
// union of both legs on a return trip (see Step4).
|
|
50
|
-
const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, seatsOptional, seatsFee, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element => {
|
|
59
|
+
const Passenger = ({ type, number, values, fields, askEmail, withReturn, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, seatsOptional, seatsFee, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element => {
|
|
51
60
|
const name = `${ type }${ number }`;
|
|
52
61
|
|
|
53
62
|
const names = {
|
|
@@ -65,7 +74,13 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
|
|
|
65
74
|
// Edited: Claude - Date: 2026-08-20
|
|
66
75
|
// "Remember this passenger" - obtapi reads `{type}{i}_save` alongside
|
|
67
76
|
// the `_fn` / `_pass` fields this form already posts.
|
|
68
|
-
save: `${ name }_save
|
|
77
|
+
save: `${ name }_save`,
|
|
78
|
+
|
|
79
|
+
// the counter-sale email (audit A4-05). obtapi already reads
|
|
80
|
+
// `{type}{i}_email` + `_notify_email` - see Orders\Make\Tickets - it
|
|
81
|
+
// was only the web form that never offered them.
|
|
82
|
+
email: `${ name }_email`,
|
|
83
|
+
notifyEmail: `${ name }_notify_email`
|
|
69
84
|
};
|
|
70
85
|
|
|
71
86
|
const has = (field: string): boolean => fields.includes(field);
|
|
@@ -340,6 +355,31 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
|
|
|
340
355
|
</div>
|
|
341
356
|
) }
|
|
342
357
|
|
|
358
|
+
{ askEmail && (
|
|
359
|
+
<div className="column">
|
|
360
|
+
<div className="row">
|
|
361
|
+
<label htmlFor={ id(names.email) }>{ t('routes_order.step4.email') }</label>
|
|
362
|
+
|
|
363
|
+
<input
|
|
364
|
+
type="email"
|
|
365
|
+
key={ `${ names.email }-${ applied }` }
|
|
366
|
+
id={ id(names.email) }
|
|
367
|
+
defaultValue={ start(names.email, undefined) }
|
|
368
|
+
{ ...refs(names.email, Validate('email|max_length:255', t)) }
|
|
369
|
+
/>
|
|
370
|
+
|
|
371
|
+
{ Display(errors[names.email]) }
|
|
372
|
+
|
|
373
|
+
{ /* the ticket goes to this address as well as to the seller -
|
|
374
|
+
obtapi gates the per-passenger copy on `_notify_email`, so
|
|
375
|
+
an address typed here has to arrive with the flag set */ }
|
|
376
|
+
<input type="hidden" value="1" { ...refs(names.notifyEmail) } />
|
|
377
|
+
|
|
378
|
+
<Notice>{ t('routes_order.step4.email_notice') }</Notice>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
) }
|
|
382
|
+
|
|
343
383
|
{ (has('whatsapp') || has('telegram')) && (
|
|
344
384
|
<div className="column">
|
|
345
385
|
{ has('whatsapp') && (
|
|
@@ -8,6 +8,8 @@ interface Props {
|
|
|
8
8
|
type: 'adult' | 'child' | 'baby'
|
|
9
9
|
amount: number
|
|
10
10
|
fields: string[]
|
|
11
|
+
/** staff sellers are offered an optional passenger email (audit A4-05) */
|
|
12
|
+
askEmail?: boolean
|
|
11
13
|
withReturn: boolean
|
|
12
14
|
values?: PersonData
|
|
13
15
|
busFrom: OccupiedData
|
|
@@ -25,7 +27,7 @@ interface Props {
|
|
|
25
27
|
onUpdate: (name: string, value: (string | number)) => void
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
const Display = ({ type, amount, fields, withReturn, values, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, seatsOptional, seatsFee, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element[] => {
|
|
30
|
+
const Display = ({ type, amount, fields, askEmail, withReturn, values, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, seatsOptional, seatsFee, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element[] => {
|
|
29
31
|
const passengers: JSX.Element[] = [];
|
|
30
32
|
|
|
31
33
|
for (let i = 1; i <= amount; i++) {
|
|
@@ -35,6 +37,7 @@ const Display = ({ type, amount, fields, withReturn, values, busFrom, busTo, pic
|
|
|
35
37
|
type={ type }
|
|
36
38
|
number={ i }
|
|
37
39
|
fields={ fields }
|
|
40
|
+
askEmail={ askEmail }
|
|
38
41
|
withReturn={ withReturn }
|
|
39
42
|
values={ values }
|
|
40
43
|
busFrom={ busFrom }
|
|
@@ -11,6 +11,8 @@ interface Props {
|
|
|
11
11
|
values?: PersonData
|
|
12
12
|
step1: RoutesSearchForm
|
|
13
13
|
fields: string[]
|
|
14
|
+
/** staff sellers are offered an optional passenger email (audit A4-05) */
|
|
15
|
+
askEmail?: boolean
|
|
14
16
|
busFrom: OccupiedData
|
|
15
17
|
busTo: OccupiedData
|
|
16
18
|
/**
|
|
@@ -38,7 +40,7 @@ interface Props {
|
|
|
38
40
|
onUpdate: (name: string, value: (string | number)) => void
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
const Passengers = ({ values, step1, fields, busFrom, busTo, saved, savedMax, seatsOptional, seatsFee, onPickedCount, t, errors, refs, onUpdate }: Props): JSX.Element => {
|
|
43
|
+
const Passengers = ({ values, step1, fields, askEmail, busFrom, busTo, saved, savedMax, seatsOptional, seatsFee, onPickedCount, t, errors, refs, onUpdate }: Props): JSX.Element => {
|
|
42
44
|
const [ pickedFrom, setPickedFrom ] = useState<number[]>([]);
|
|
43
45
|
const [ pickedTo, setPickedTo ] = useState<number[]>([]);
|
|
44
46
|
|
|
@@ -84,6 +86,7 @@ const Passengers = ({ values, step1, fields, busFrom, busTo, saved, savedMax, se
|
|
|
84
86
|
type="adult"
|
|
85
87
|
amount={ step1.adults }
|
|
86
88
|
fields={ fields }
|
|
89
|
+
askEmail={ askEmail }
|
|
87
90
|
withReturn={ step1.type === 'return' }
|
|
88
91
|
values={ values }
|
|
89
92
|
busFrom={ busFrom }
|
|
@@ -105,6 +108,7 @@ const Passengers = ({ values, step1, fields, busFrom, busTo, saved, savedMax, se
|
|
|
105
108
|
type="child"
|
|
106
109
|
amount={ step1.children }
|
|
107
110
|
fields={ fields }
|
|
111
|
+
askEmail={ askEmail }
|
|
108
112
|
withReturn={ step1.type === 'return' }
|
|
109
113
|
values={ values }
|
|
110
114
|
busFrom={ busFrom }
|
|
@@ -126,6 +130,7 @@ const Passengers = ({ values, step1, fields, busFrom, busTo, saved, savedMax, se
|
|
|
126
130
|
type="baby"
|
|
127
131
|
amount={ step1.babies }
|
|
128
132
|
fields={ fields }
|
|
133
|
+
askEmail={ askEmail }
|
|
129
134
|
withReturn={ step1.type === 'return' }
|
|
130
135
|
values={ values }
|
|
131
136
|
busFrom={ busFrom }
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
1
2
|
import { TFunction } from 'i18next';
|
|
2
3
|
import { useForm } from 'react-hook-form';
|
|
3
4
|
import { RiCoupon3Line } from 'react-icons/ri';
|
|
4
5
|
import { Validate, Display } from '@autobusal/utilities';
|
|
5
|
-
import { Container, ContainerForm, ContainerInput, Apply } from './styles';
|
|
6
|
+
import { Container, ContainerForm, ContainerInput, Apply, Message } from './styles';
|
|
6
7
|
import { CouponData } from '@autobusal/providers/types/orders';
|
|
7
8
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
8
9
|
import { TotalData, CouponForm } from '../../types';
|
|
@@ -26,7 +27,25 @@ const Coupons = ({ current, step2, t, total, onApplied }: Props): (JSX.Element |
|
|
|
26
27
|
// onSave, so a real local numeric id is guaranteed here.
|
|
27
28
|
const { mutate: SearchCoupons, isPending } = useGetCoupon(step2.id as number, total.value);
|
|
28
29
|
|
|
30
|
+
/*
|
|
31
|
+
* Claude - 2026-08-29 (audit A3-10c, medium)
|
|
32
|
+
*
|
|
33
|
+
* A CODE THAT DOES NOT WORK NOW SAYS SO.
|
|
34
|
+
*
|
|
35
|
+
* There were two silences here, and the second was total. A code the
|
|
36
|
+
* server recognised but refused (expired, spent, not for this route) got
|
|
37
|
+
* an `alert()`, which some browsers suppress and none of them place next
|
|
38
|
+
* to the field. A code that does not exist at all never reached this
|
|
39
|
+
* handler: the request 4xx's, `onSuccess` does not run, and there was no
|
|
40
|
+
* `onError` - so the commonest case of all, a typo, cleared the spinner
|
|
41
|
+
* and changed nothing on screen. A buyer cannot tell that from a coupon
|
|
42
|
+
* that silently applied, and the total beside it does not move either way.
|
|
43
|
+
*/
|
|
44
|
+
const [ message, setMessage ] = useState<{ text: string, error: boolean } | null>(null);
|
|
45
|
+
|
|
29
46
|
const onSubmit = (data: CouponForm): void => {
|
|
47
|
+
setMessage(null);
|
|
48
|
+
|
|
30
49
|
SearchCoupons(data, {
|
|
31
50
|
onSuccess: (data) => {
|
|
32
51
|
if (data.used) {
|
|
@@ -35,7 +54,10 @@ const Coupons = ({ current, step2, t, total, onApplied }: Props): (JSX.Element |
|
|
|
35
54
|
return;
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
|
|
57
|
+
setMessage({ text: t('routes_order.step5.coupon.error'), error: true });
|
|
58
|
+
},
|
|
59
|
+
onError: () => {
|
|
60
|
+
setMessage({ text: t('routes_order.step5.coupon.error'), error: true });
|
|
39
61
|
}
|
|
40
62
|
});
|
|
41
63
|
};
|
|
@@ -77,6 +99,12 @@ const Coupons = ({ current, step2, t, total, onApplied }: Props): (JSX.Element |
|
|
|
77
99
|
</ContainerInput>
|
|
78
100
|
|
|
79
101
|
{ Display(errors.code) }
|
|
102
|
+
|
|
103
|
+
{ message && (
|
|
104
|
+
<Message $error={ message.error } role="status" aria-live="polite">
|
|
105
|
+
{ message.text }
|
|
106
|
+
</Message>
|
|
107
|
+
) }
|
|
80
108
|
</ContainerForm>
|
|
81
109
|
</form>
|
|
82
110
|
</Container>
|
package/Step5/Coupons/styles.ts
CHANGED
|
@@ -26,6 +26,20 @@ export const ContainerInput = styled.div`
|
|
|
26
26
|
}
|
|
27
27
|
`;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* What happened to the code that was just typed.
|
|
31
|
+
*
|
|
32
|
+
* Claude - 2026-08-29 (audit A3-10c, medium). Inline, directly under the
|
|
33
|
+
* field, which is where this form's own validation message already appears -
|
|
34
|
+
* a buyer who mistypes a code is looking at the box, not anywhere else. Two
|
|
35
|
+
* colours because "applied" is the answer this form exists to give and it
|
|
36
|
+
* should not look like a failure.
|
|
37
|
+
*/
|
|
38
|
+
export const Message = styled.div<{ $error?: boolean }>`
|
|
39
|
+
font-size: ${ props => props.theme.size.s };
|
|
40
|
+
color: ${ props => (props.$error ? props.theme.font.error : props.theme.font.success) };
|
|
41
|
+
`;
|
|
42
|
+
|
|
29
43
|
export const Apply = styled.button`
|
|
30
44
|
position: absolute;
|
|
31
45
|
top: 50%;
|
|
@@ -14,11 +14,17 @@ interface Props {
|
|
|
14
14
|
isPartner: boolean
|
|
15
15
|
routeId?: number | string
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* What the payer OWES - the base the surcharge is drawn against - and its
|
|
18
|
+
* currency.
|
|
18
19
|
*
|
|
19
20
|
* Claude - 2026-08-29 (ruling by Ferjolt): each card button says what it
|
|
20
21
|
* adds, so the cost of choosing a card is visible BEFORE it is chosen -
|
|
21
22
|
* the summary line can only ever explain the method already picked.
|
|
23
|
+
*
|
|
24
|
+
* Claude - 2026-08-29 (audit A4-07): the OWED figure, not the basket. A
|
|
25
|
+
* reseller settles the fare minus their own commission and is surcharged on
|
|
26
|
+
* that, so passing the gross made every button quote a fee larger than the
|
|
27
|
+
* one the summary directly underneath it - and the bank - would charge.
|
|
22
28
|
*/
|
|
23
29
|
total?: number
|
|
24
30
|
currency?: string
|
package/package.json
CHANGED
package/services.ts
CHANGED
|
@@ -292,6 +292,61 @@ export const useGetPayments = (routeId?: number | string): UseQueryResult<Action
|
|
|
292
292
|
})
|
|
293
293
|
);
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* What commission the signed-in seller earns on this booking - and so what
|
|
297
|
+
* they, rather than a passenger, actually owe for it.
|
|
298
|
+
*
|
|
299
|
+
* Claude - 2026-08-29 (audit A4-07, medium)
|
|
300
|
+
*
|
|
301
|
+
* A reseller pays the fare MINUS their own commission (obtapi Payments\Owed)
|
|
302
|
+
* and the card surcharge is charged on that net. The checkout knew none of
|
|
303
|
+
* it: it computed the surcharge on the gross basket and printed a total to
|
|
304
|
+
* match, so an agent was shown 67.05 and the bank then asked for 60.67.
|
|
305
|
+
* Neither number on screen before "Make Payment" was the one that would be
|
|
306
|
+
* charged.
|
|
307
|
+
*
|
|
308
|
+
* POST, because this endpoint already exists in exactly this shape for the
|
|
309
|
+
* mobile checkout and takes the same search inputs order creation does - so a
|
|
310
|
+
* round trip is quoted as a PAIR and the commission is computed on the
|
|
311
|
+
* same-operator fare rather than on two one-ways added together.
|
|
312
|
+
*
|
|
313
|
+
* Enabled for seller types only: nobody else earns anything, the endpoint
|
|
314
|
+
* short-circuits them to zero, and a guest checkout should not spend a
|
|
315
|
+
* request being told so.
|
|
316
|
+
*/
|
|
317
|
+
export const useGetComission = (
|
|
318
|
+
enabled: boolean,
|
|
319
|
+
departure: string,
|
|
320
|
+
departurePriceId: (number | string),
|
|
321
|
+
adults: number,
|
|
322
|
+
children: number,
|
|
323
|
+
babies: number,
|
|
324
|
+
_return?: string,
|
|
325
|
+
returnPriceId?: (number | string)
|
|
326
|
+
): UseQueryResult<{ comission_agent: number }> => (
|
|
327
|
+
useQuery({
|
|
328
|
+
queryKey: ['quote-comission', { departure, departurePriceId, adults, children, babies, _return, returnPriceId }],
|
|
329
|
+
// a synthetic external-provider price id can never be quoted locally
|
|
330
|
+
enabled: enabled && typeof departurePriceId === 'number',
|
|
331
|
+
queryFn: async () => (
|
|
332
|
+
await apiClient
|
|
333
|
+
// no fromId/toId: the endpoint derives the cities from the departure
|
|
334
|
+
// price row, the way order creation does (Orders\Make\Prepare)
|
|
335
|
+
.post('/api/orders/quote/departure', {
|
|
336
|
+
departure,
|
|
337
|
+
adults,
|
|
338
|
+
children,
|
|
339
|
+
babies,
|
|
340
|
+
departure_price_id: departurePriceId,
|
|
341
|
+
...(_return !== undefined && typeof returnPriceId === 'number'
|
|
342
|
+
? { _return, return_price_id: returnPriceId }
|
|
343
|
+
: {})
|
|
344
|
+
})
|
|
345
|
+
.then(response => response.data)
|
|
346
|
+
)
|
|
347
|
+
})
|
|
348
|
+
);
|
|
349
|
+
|
|
295
350
|
/**
|
|
296
351
|
* The Flexible Ticket offer for the routes actually chosen.
|
|
297
352
|
*
|