@autobusal/routes-order 1.33.7 → 1.33.9

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.33.9 (2026-08-28)
4
+
5
+ - 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).
6
+ - 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).
7
+ - useGetFlex takes the coupon-adjusted total so the quoted flex price matches what the server bills on a couponed booking (sweep #53).
8
+ - Passenger age bands match the server rules: adult 12+, child 2-12, baby under 2 (sweep #48).
9
+
10
+ ## 1.33.8 (2026-08-28)
11
+
12
+ - usePostOrder replays the stored referral token as `referral`, so widget/link arrivals attribute their affiliate at checkout.
13
+
3
14
  ## 1.33.7 (2026-08-26)
4
15
 
5
16
  - Seat-selection add-on: checkout reads settings addons.seats - picking turns optional with the fee on the CTA and a live summary line; submission strips unpicked (zero) seat fields and sends addon_seats only when a hand-picked seat remains. Fixes the return-leg picked-seat list rebuilding from the departure's on re-pick.
@@ -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/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.7",
3
+ "version": "1.33.9",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/services.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useMutation, UseMutationResult, useQuery, UseQueryResult } from '@tanstack/react-query';
2
2
  import { AxiosError } from 'axios';
3
- import { apiClient } from '@autobusal/providers';
3
+ import { apiClient, storedReferral } from '@autobusal/providers';
4
4
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
5
5
  import { FoundData, FlexOffer } from '@autobusal/providers/types/routes';
6
6
  import { CouponData } from '@autobusal/providers/types/orders';
@@ -224,6 +224,21 @@ export const usePostOrder = (
224
224
  order.action = action;
225
225
  }
226
226
 
227
+ /*
228
+ * Edited: Claude - Date: 2026-08-28
229
+ *
230
+ * The referral token minted when this visitor arrived through ?ref=
231
+ * (providers useReferralTouch) - replayed under the exact field name
232
+ * obtapi's Attribution::FIELD declares. Expiry is checked client-side
233
+ * only to avoid sending a corpse; the server re-validates everything
234
+ * and a stale or self-referred token simply attributes nothing.
235
+ */
236
+ const referral = storedReferral();
237
+
238
+ if (referral !== null) {
239
+ order.referral = referral.token;
240
+ }
241
+
227
242
  if (data !== undefined) {
228
243
  Object.keys(data).forEach(index => {
229
244
  order[index] = data[index as keyof typeof data];
@@ -296,10 +311,14 @@ export const useGetFlex = (
296
311
  returnPriceId: (number | undefined),
297
312
  adults: number,
298
313
  children: number,
299
- 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
300
319
  ): UseQueryResult<FlexOffer> => (
301
320
  useQuery({
302
- queryKey: ['flex-offer', { departurePriceId, returnPriceId, adults, children, babies }],
321
+ queryKey: ['flex-offer', { departurePriceId, returnPriceId, adults, children, babies, total }],
303
322
  enabled: departurePriceId !== undefined,
304
323
  queryFn: async () => (
305
324
  await apiClient
@@ -309,7 +328,8 @@ export const useGetFlex = (
309
328
  return_price_id: returnPriceId,
310
329
  adults,
311
330
  children,
312
- babies
331
+ babies,
332
+ ...(total !== undefined ? { total } : {})
313
333
  }
314
334
  })
315
335
  .then(response => response.data)