@autobusal/routes-order 1.34.3 → 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 CHANGED
@@ -1,3 +1,15 @@
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
+
1
13
  ## 1.34.3 - 2026-08-29
2
14
 
3
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.
@@ -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
- const surcharge = surchargeOf(grandTotal.value, action, paymentsData);
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 payable: TotalData = surcharge > 0 ? {
292
- display: `${ Math.round((grandTotal.value + surcharge) * 100) / 100 } ${ currency }`,
293
- value: Math.round((grandTotal.value + surcharge) * 100) / 100
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,6 +558,10 @@ 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 }
@@ -508,6 +631,10 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
508
631
  coupon={ coupon }
509
632
  total={ payable }
510
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 }
511
638
  action={ action }
512
639
  single={ singleMethod }
513
640
  pending={ isPending }
@@ -518,7 +645,9 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
518
645
  selected={ action ?? '' }
519
646
  isPartner={ isPartner ?? false }
520
647
  routeId={ step2.id }
521
- total={ grandTotal.value }
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 }
522
651
  currency={ currency }
523
652
  t={ t }
524
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
@@ -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
- { best.duration !== '-' && best.duration }
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
@@ -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) } /> }
@@ -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
- setStep1(data);
187
- goto(null, true);
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 _return = isPreparedDate(requested)
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;
@@ -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
- alert(t('routes_order.step5.coupon.error'));
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>
@@ -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
- * The checkout total the surcharge is drawn against, and its currency.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.34.3",
3
+ "version": "1.34.4",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
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
  *