@autobusal/routes-order 1.33.8 → 1.33.10

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,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.33.10 (2026-08-29)
4
+
5
+ - The return date falls back to the outbound when the URL carries no real one ('none' on a one-way link), so switching a collapsed search to Round Trip opens the calendar on the month being travelled and cannot offer a day before the departure.
6
+ - The collapsed search panel sits above the step markers: its backdrop-filter creates a stacking context, so the date picker used to draw underneath them.
7
+
8
+ ## 1.33.9 (2026-08-28)
9
+
10
+ - Return-leg empty state fetches RETURN alternatives (swapped pair + return date) and its suggested-day links fill the return slot instead of rewriting the departure; nearby-city swaps map back to the URL's outbound pair (sweep #50).
11
+ - Checkout money parsing survives the server's space-thousands format: currency is the last token, coupon amounts strip grouping spaces - totals over 1000 no longer corrupt the rendered total or the GA4 values (sweep #51).
12
+ - useGetFlex takes the coupon-adjusted total so the quoted flex price matches what the server bills on a couponed booking (sweep #53).
13
+ - Passenger age bands match the server rules: adult 12+, child 2-12, baby under 2 (sweep #48).
14
+
3
15
  ## 1.33.8 (2026-08-28)
4
16
 
5
17
  - usePostOrder replays the stored referral token as `referral`, so widget/link arrivals attribute their affiliate at checkout.
@@ -15,7 +15,7 @@ import Addons from '../Step5/Addons/Addons';
15
15
  import Billing from '../Step5/Billing/Billing';
16
16
  import Payments from '../Step5/Payments/Payments';
17
17
  import Sidebar from './Sidebar/Sidebar';
18
- import { getTotal, convertCoupon, getAvailablePayments } from '../Step5/utilities';
18
+ import { getTotal, convertCoupon, getAvailablePayments, currencyOf } from '../Step5/utilities';
19
19
  import { Title, Actions, Results, Listing } from '../styles';
20
20
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
21
21
  import { PersonData, BillingData } from '@autobusal/providers/types/persons';
@@ -179,7 +179,9 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
179
179
  const busFrom = useBusOccupied(step1.departure, step2.id as number);
180
180
  const busTo = useBusOccupied(step1._return, step3?.id as (number | undefined));
181
181
 
182
- const currency = total.display.split(' ')[1] ?? '';
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);
183
185
 
184
186
  // the brand sells seat choice: picking turns optional (auto-assign is the
185
187
  // free default) and each hand-picked seat costs this
@@ -195,7 +197,9 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
195
197
  step3?.price.id as (number | undefined),
196
198
  step1.adults ?? 1,
197
199
  step1.children ?? 0,
198
- step1.babies ?? 0
200
+ step1.babies ?? 0,
201
+ // the coupon-adjusted fare, so the quote and the charge share a base
202
+ total.value
199
203
  );
200
204
 
201
205
  /**
@@ -4,6 +4,7 @@ import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
4
  import { AlternativesData, NearbyCity, FoundDay } from '../../types';
5
5
 
6
6
  interface Props {
7
+ type: 'departure' | '_return'
7
8
  step1: RoutesSearchForm
8
9
  data?: AlternativesData
9
10
  loading: boolean
@@ -25,29 +26,51 @@ interface Props {
25
26
  * even when the further city is cheaper: the passenger's problem here is
26
27
  * getting to the bus, not saving two euros.
27
28
  */
28
- const Empty = ({ step1, data, loading, t }: Props): JSX.Element => {
29
+ const Empty = ({ type, step1, data, loading, t }: Props): JSX.Element => {
29
30
  // obtapi wants DD/MM/YYYY, the URL carries DD-MM-YYYY
30
31
  const asPath = (date: string): string => date.replace(/\//g, '-');
31
32
 
32
- const link = (from: string, to: string, departure: string): string => ([
33
+ /*
34
+ * Edited: Claude - Date: 2026-08-28 (sweep, low)
35
+ *
36
+ * The suggestions here are now leg-aware. The URL always carries the
37
+ * OUTBOUND pair and both dates, but on the return leg the search that
38
+ * came back empty is destination->origin on the return date - so a
39
+ * suggested day must land in the RETURN slot (the old link moved the
40
+ * departure the buyer never asked to change and led straight back into
41
+ * the same empty step), and a nearby city for the return's origin is a
42
+ * replacement for the URL's `to`, not its `from`.
43
+ */
44
+ const isReturn = type === '_return';
45
+
46
+ const link = (from: string, to: string, departure: string, _return: string | undefined): string => ([
33
47
  '/bus-lines',
34
48
  from,
35
49
  to,
36
50
  asPath(departure),
37
- step1._return ? asPath(step1._return) : 'none',
51
+ _return ? asPath(_return) : 'none',
38
52
  step1.type ?? 'departure',
39
53
  String(step1.adults),
40
54
  String(step1.children),
41
55
  String(step1.babies)
42
56
  ].join('/'));
43
57
 
58
+ const cityLink = (item: NearbyCity, side: 'from' | 'to'): string => {
59
+ // `side` is relative to the leg that was searched; map it back to the
60
+ // URL's outbound pair
61
+ if (side === 'from') {
62
+ return isReturn
63
+ ? link(step1.from, item.city.slug, step1.departure, step1._return)
64
+ : link(item.city.slug, step1.to, step1.departure, step1._return);
65
+ }
66
+
67
+ return isReturn
68
+ ? link(item.city.slug, step1.to, step1.departure, step1._return)
69
+ : link(step1.from, item.city.slug, step1.departure, step1._return);
70
+ };
71
+
44
72
  const city = (item: NearbyCity, side: 'from' | 'to'): JSX.Element => (
45
- <Option
46
- key={ item.city.id }
47
- to={ side === 'from'
48
- ? link(item.city.slug, step1.to, step1.departure)
49
- : link(step1.from, item.city.slug, step1.departure) }
50
- >
73
+ <Option key={ item.city.id } to={ cityLink(item, side) }>
51
74
  <OptionName title={ item.city.name }>{ item.city.name }</OptionName>
52
75
  <OptionMeta>{ t('routes_order.step2.empty.distance', { km: item.distance }) }</OptionMeta>
53
76
  <OptionPrice>{ item.from_price_display }</OptionPrice>
@@ -55,7 +78,12 @@ const Empty = ({ step1, data, loading, t }: Props): JSX.Element => {
55
78
  );
56
79
 
57
80
  const day = (item: FoundDay): JSX.Element => (
58
- <Option key={ item.id } to={ link(step1.from, step1.to, item.day) }>
81
+ <Option
82
+ key={ item.id }
83
+ to={ isReturn
84
+ ? link(step1.from, step1.to, step1.departure, item.day)
85
+ : link(step1.from, step1.to, item.day, step1._return) }
86
+ >
59
87
  <OptionName>{ item.day }</OptionName>
60
88
  <OptionMeta>{ t('routes_order.step2.empty.available') }</OptionMeta>
61
89
  { item.from_price_display && <OptionPrice>{ item.from_price_display }</OptionPrice> }
package/Found/Found.tsx CHANGED
@@ -91,8 +91,21 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
91
91
  // Edited: Ferjolt Ozuni - Date: 2026-08-01
92
92
  // Only fetched once the search itself has finished and come back empty,
93
93
  // so a search that found something never pays for the alternatives.
94
+ /*
95
+ * Edited: Claude - Date: 2026-08-28 (sweep, low)
96
+ *
97
+ * On the return leg the journey with no results is destination->origin
98
+ * on the RETURN date - the same swap useGetDates and `watched` below
99
+ * have always made. Passing step1 unswapped asked the API for OUTBOUND
100
+ * alternatives, so the empty return step suggested days for a leg the
101
+ * buyer already has (including the very day they booked).
102
+ */
103
+ const alternativesQuery = type === '_return'
104
+ ? { ...step1, from: step1.to, to: step1.from, departure: step1._return ?? '' }
105
+ : step1;
106
+
94
107
  const { data: alternatives, isFetching: alternativesLoading } = useGetAlternatives(
95
- step1,
108
+ alternativesQuery,
96
109
  !loading && items.length === 0
97
110
  );
98
111
 
@@ -43,7 +43,7 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
43
43
  // confirmed actually have seats.
44
44
  if (empty) {
45
45
  return (
46
- <Empty step1={ step1 } data={ alternatives } loading={ alternativesLoading } t={ t } />
46
+ <Empty type={ type } step1={ step1 } data={ alternatives } loading={ alternativesLoading } t={ t } />
47
47
  );
48
48
  }
49
49
 
package/Step1/prepare.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Params } from 'react-router-dom';
2
- import { futureDate } from '@autobusal/utilities';
2
+ import { futureDate, isPreparedDate } from '@autobusal/utilities';
3
3
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
4
 
5
5
  const prepare = (
@@ -18,7 +18,26 @@ const prepare = (
18
18
  const to = params.to ?? defaultLocations.to;
19
19
 
20
20
  const departure = params.departure?.replace(/-/g, '/') ?? futureDate(1);
21
- const _return = params._return?.replace(/-/g, '/') ?? futureDate(2);
21
+
22
+ /*
23
+ * Edited: Claude - Date: 2026-08-29
24
+ *
25
+ * THE RETURN DATE IS TIED TO THE OUTBOUND. A one-way URL carries the
26
+ * literal "none" in the return slot, and `??` only catches null - so
27
+ * "none" travelled all the way into the picker, whose grid is built by
28
+ * splitting on "/" and feeding Date.UTC: an Invalid Date, rendered as
29
+ * January. Switching a collapsed search from One Way to Round Trip
30
+ * showed exactly that.
31
+ *
32
+ * Anything that is not a real date falls back to the departure, which is
33
+ * also the picker's floor - so the return calendar opens on the month
34
+ * being travelled and cannot offer a day before the outbound.
35
+ */
36
+ const requested = params._return?.replace(/-/g, '/');
37
+
38
+ const _return = isPreparedDate(requested)
39
+ ? String(requested)
40
+ : (isPreparedDate(departure) ? departure : futureDate(2));
22
41
 
23
42
  const adults = params.adults ?? 1;
24
43
  const children = params.children ?? 0;
package/Step4/checkAge.ts CHANGED
@@ -3,12 +3,23 @@ import { error, differenceYears, createDate } from '@autobusal/utilities';
3
3
  import { PersonData } from '@autobusal/providers/types/persons';
4
4
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
5
5
 
6
+ /**
7
+ * Edited: Claude - Date: 2026-08-28 (sweep finding, medium)
8
+ *
9
+ * ONE SET OF BANDS, the ones the picker advertises in all 15 languages:
10
+ * adults 12+, children 2-12, babies under 2. This check used to say
11
+ * adult>=18 / child 3-17 / baby<=2 while the server said something
12
+ * different again - a 2-year-old could not be booked in any category and
13
+ * a teenager the copy calls an adult was refused at pay time. The
14
+ * boundaries mirror obtapi's RulesGeneral::getDob exactly (2nd birthday
15
+ * makes a child, 12th an adult).
16
+ */
6
17
  const checkAge = (step1: RoutesSearchForm, data: PersonData, t: TFunction<'general'>): boolean => {
7
18
  // check adults ages
8
19
  for (let i = 1; i <= step1.adults; i++) {
9
20
  const age = getAge(data[`adult${ i }_dob`]);
10
21
 
11
- if (age < 18) {
22
+ if (age < 12) {
12
23
  error(t('routes_order.step4.errors.age'));
13
24
  return false;
14
25
  }
@@ -18,7 +29,7 @@ const checkAge = (step1: RoutesSearchForm, data: PersonData, t: TFunction<'gener
18
29
  for (let i = 1; i <= step1.children; i++) {
19
30
  const age = getAge(data[`child${ i }_dob`])
20
31
 
21
- if (age < 3 || age > 17) {
32
+ if (age < 2 || age >= 12) {
22
33
  error(t('routes_order.step4.errors.age'));
23
34
  return false;
24
35
  }
@@ -28,7 +39,7 @@ const checkAge = (step1: RoutesSearchForm, data: PersonData, t: TFunction<'gener
28
39
  for (let i = 1; i <= step1.babies; i++) {
29
40
  const age = getAge(data[`baby${ i }_dob`]);
30
41
 
31
- if (age > 2) {
42
+ if (age >= 2) {
32
43
  error(t('routes_order.step4.errors.age'));
33
44
  return false;
34
45
  }
@@ -45,4 +56,4 @@ const getAge = (date: string | number): number => {
45
56
  return age;
46
57
  };
47
58
 
48
- export default checkAge;
59
+ export default checkAge;
@@ -2,26 +2,39 @@ import { FoundData } from '@autobusal/providers/types/routes';
2
2
  import { CouponData } from '@autobusal/providers/types/orders';
3
3
  import { ActionData, TotalData } from '../types';
4
4
 
5
- export const getTotal = (step2: FoundData, step3?: FoundData): TotalData => {
6
- // we get the currency
7
- const currency = step2.price.display.split(' ');
5
+ /*
6
+ * Edited: Claude - Date: 2026-08-28 (sweep, low)
7
+ *
8
+ * The server formats thousands with a SPACE ('1 250.00 EUR', Fundable
9
+ * 2026-08-19), so split(' ')[0]/[1] read the wrong pieces the moment a
10
+ * basket crosses 1000 - the currency became '250.00' and a couponed total
11
+ * became the number 1, corrupting the rendered total and the GA4 values.
12
+ * The currency is the LAST token; the amount is everything else with the
13
+ * grouping spaces stripped.
14
+ */
15
+ export const currencyOf = (display: string): string => (
16
+ display.trim().split(' ').pop() ?? ''
17
+ );
8
18
 
19
+ export const amountOf = (display: string): number => (
20
+ Number(display.trim().split(' ').slice(0, -1).join(''))
21
+ );
22
+
23
+ export const getTotal = (step2: FoundData, step3?: FoundData): TotalData => {
9
24
  const total = step2.price.value + (step3?.price.value ?? 0);
10
25
 
11
26
  return {
12
- display: `${ total } ${ currency[1] }`,
27
+ display: `${ total } ${ currencyOf(step2.price.display) }`,
13
28
  value: total
14
29
  };
15
30
  };
16
31
 
17
- export const convertCoupon = (data: CouponData): TotalData => {
18
- const currency = data.total.split(' ');
19
-
20
- return {
32
+ export const convertCoupon = (data: CouponData): TotalData => (
33
+ {
21
34
  display: data.total,
22
- value: Number(currency[0])
23
- };
24
- };
35
+ value: amountOf(data.total)
36
+ }
37
+ );
25
38
 
26
39
  /**
27
40
  * The payment methods actually offered on this checkout, in the order the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.33.8",
3
+ "version": "1.33.10",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/services.ts CHANGED
@@ -311,10 +311,14 @@ export const useGetFlex = (
311
311
  returnPriceId: (number | undefined),
312
312
  adults: number,
313
313
  children: number,
314
- babies: number
314
+ babies: number,
315
+ // Edited: Claude - Date: 2026-08-28 (sweep, low): the coupon-adjusted
316
+ // fare, so the quoted flex price matches what Make\Addons will charge -
317
+ // without it a couponed booking showed one figure and billed another
318
+ total?: number
315
319
  ): UseQueryResult<FlexOffer> => (
316
320
  useQuery({
317
- queryKey: ['flex-offer', { departurePriceId, returnPriceId, adults, children, babies }],
321
+ queryKey: ['flex-offer', { departurePriceId, returnPriceId, adults, children, babies, total }],
318
322
  enabled: departurePriceId !== undefined,
319
323
  queryFn: async () => (
320
324
  await apiClient
@@ -324,7 +328,8 @@ export const useGetFlex = (
324
328
  return_price_id: returnPriceId,
325
329
  adults,
326
330
  children,
327
- babies
331
+ babies,
332
+ ...(total !== undefined ? { total } : {})
328
333
  }
329
334
  })
330
335
  .then(response => response.data)
package/styles.ts CHANGED
@@ -94,6 +94,19 @@ export const SearchPanel = styled.div<{ $open: boolean }>`
94
94
  display: ${ props => props.$open ? 'block' : 'none' };
95
95
  margin-top: 14px;
96
96
  padding: 20px;
97
+
98
+ /**
99
+ * Edited: Claude - Date: 2026-08-29
100
+ *
101
+ * ABOVE THE STEPS, because backdrop-filter below creates a stacking
102
+ * context: everything inside this panel is confined to it, so the date
103
+ * picker's own z-index (100) could never beat the step markers sitting
104
+ * at z-index 1 in the page's context. Opening the collapsed search and
105
+ * clicking a date drew the calendar UNDERNEATH the 1-2-3-4 circles.
106
+ * Raising the panel itself lifts its whole subtree with it.
107
+ */
108
+ position: relative;
109
+ z-index: 20;
97
110
  background: ${ props => props.theme.header.transparent };
98
111
  backdrop-filter: blur(25px);
99
112
  -webkit-backdrop-filter: blur(25px);