@autobusal/routes-order 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,38 @@
3
3
  All notable changes to this package are documented here. This project follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [1.6.1] - 2026-07-27
7
+
8
+ ### Added
9
+
10
+ - **External-provider "Book on {provider}" redirect (Mode A: search +
11
+ redirect).** `Found/Route/Route.tsx` now detects `data.external?.mode ===
12
+ 'redirect'` (obtapi's `Libraries\External\Search\Merge`) and swaps the
13
+ usual "Select Route" button for a `t('routes_order.step2.route.book_on')`
14
+ button that opens the click-out endpoint
15
+ (`{VITE_API_OBT}/api/external/go/{offer_id}`) in a new tab instead of
16
+ calling `onSave` - external offers use a synthetic id the local checkout
17
+ pipeline can't resolve, so "select" is never wired for them. The operator
18
+ logo and route-code links are also repointed to the same click-out URL
19
+ (not the raw provider deep link) so every outbound path is logged for the
20
+ search->click funnel, and the local-only "Read Operator Policy" button is
21
+ hidden for external rows (no local policy content exists for them).
22
+ Requires `@autobusal/providers` ^1.3.7.
23
+
24
+ ## [1.6.0] - 2026-07-25
25
+
26
+ ### Added
27
+
28
+ - **Checkout addon selection (Step5).** Buyers can now add WhatsApp and/or
29
+ Telegram notification addons at checkout, if the label has them enabled
30
+ (`SettingsData.addons` from `@autobusal/providers`). WhatsApp collects a
31
+ phone number inline; Telegram is a "connect after purchase" flow (a bot
32
+ can't message a bare phone number cold). Selected addons render as line
33
+ items in the order summary and are folded into the grand total shown
34
+ before payment - pricing/availability is re-verified server-side, this is
35
+ only what the buyer requests. `usePostOrder` now takes an `AddonsSelection`
36
+ and posts `addon_whatsapp`/`addon_whatsapp_number`/`addon_telegram`.
37
+
6
38
  ## [1.5.0] - 2026-07-25
7
39
 
8
40
  ### Added
@@ -27,6 +27,17 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
27
27
  <RouteFeature key={ index } item={ item } />
28
28
  ));
29
29
 
30
+ // Edited: Ferjolt Ozuni - Date: 2026-07-27
31
+ // Mode A (search + redirect): this row is an external-provider offer,
32
+ // not a local route - "select" would try to check out with a synthetic
33
+ // "ext:..." id, which the local pipeline can't resolve. Every outbound
34
+ // click goes through the click-out endpoint (not the raw deep link
35
+ // directly) so it's logged for the search->click funnel.
36
+ const isExternalRedirect = data.external?.mode === 'redirect';
37
+ const bookOnUrl = data.external
38
+ ? `${ import.meta.env.VITE_API_OBT }/api/external/go/${ data.external.offer_id }`
39
+ : undefined;
40
+
30
41
  return (
31
42
  <Container className="box" $type={ type }>
32
43
  { data.price.offer === true && <SpecialOffer>{ t('routes_order.step2.route.offer') }</SpecialOffer> }
@@ -34,7 +45,7 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
34
45
 
35
46
  <Trip>
36
47
  <Company>
37
- <LinkCompany to={ data.operator.link } target="_blank" rel="noopener noreferrer" title={ data.operator.company }><img src={ data.operator.logo_url || LOGO_FALLBACK } onError={ (e) => { e.currentTarget.onerror = null; e.currentTarget.src = LOGO_FALLBACK; } } alt={ data.operator.company } /></LinkCompany>
48
+ <LinkCompany to={ bookOnUrl ?? data.operator.link } target="_blank" rel="noopener noreferrer" title={ data.operator.company }><img src={ data.operator.logo_url || LOGO_FALLBACK } onError={ (e) => { e.currentTarget.onerror = null; e.currentTarget.src = LOGO_FALLBACK; } } alt={ data.operator.company } /></LinkCompany>
38
49
  </Company>
39
50
 
40
51
  <Location>
@@ -62,10 +73,10 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
62
73
  text={
63
74
  <>
64
75
  <RiBus2Line />
65
- { t('routes_order.step2.route.select') }
76
+ { isExternalRedirect ? t('routes_order.step2.route.book_on', { provider: data.operator.company }) : t('routes_order.step2.route.select') }
66
77
  </>
67
78
  }
68
- onClick={ () => onSave(data) }
79
+ onClick={ () => (isExternalRedirect && bookOnUrl ? window.open(bookOnUrl, '_blank', 'noopener,noreferrer') : onSave(data)) }
69
80
  />
70
81
  </Price>
71
82
  </Trip>
@@ -73,7 +84,7 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
73
84
  <Details>
74
85
  <Detail>
75
86
  <TitleDetail>{ t('routes_order.step2.route.name') }</TitleDetail>
76
- <LinkRoute to={ data.link } target="_blank" rel="noopener noreferrer">{ data.code }</LinkRoute>
87
+ <LinkRoute to={ bookOnUrl ?? data.link } target="_blank" rel="noopener noreferrer">{ data.code }</LinkRoute>
77
88
  </Detail>
78
89
 
79
90
  { customs && (
@@ -90,14 +101,16 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
90
101
  </Detail>
91
102
  ) }
92
103
 
93
- <Detail>
94
- <TitleDetail>{ t('routes_order.step2.route.policies.title') }</TitleDetail>
104
+ { !data.external && (
105
+ <Detail>
106
+ <TitleDetail>{ t('routes_order.step2.route.policies.title') }</TitleDetail>
95
107
 
96
- <ButtonPolicy type="button" onClick={ () => setPolicy(true) }>
97
- <HiOutlineDocumentText />
98
- { t('routes_order.step2.route.policies.view') }
99
- </ButtonPolicy>
100
- </Detail>
108
+ <ButtonPolicy type="button" onClick={ () => setPolicy(true) }>
109
+ <HiOutlineDocumentText />
110
+ { t('routes_order.step2.route.policies.view') }
111
+ </ButtonPolicy>
112
+ </Detail>
113
+ ) }
101
114
  </Details>
102
115
 
103
116
  { data.trip.features_data.length > 0 && (
@@ -110,7 +123,7 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
110
123
  </Features>
111
124
  ) }
112
125
 
113
- { policy && <Policy id={ data.id } t={ t } onClose={ () => setPolicy(false) } /> }
126
+ { policy && typeof data.id === 'number' && <Policy id={ data.id } t={ t } onClose={ () => setPolicy(false) } /> }
114
127
  </Container>
115
128
  );
116
129
  };
package/Step4/Step4.tsx CHANGED
@@ -40,8 +40,13 @@ const Step4 = ({ values, step1, step2, step3, isPartner, t, onSave, onBack }: Pr
40
40
  ...(step3 !== undefined ? (step3.trip?.passenger_fields ?? DEFAULT_FIELDS) : [])
41
41
  ]));
42
42
 
43
- const busFrom = useBusOccupied(step1.departure, step2.id);
44
- const busTo = useBusOccupied(step1._return, step3?.id);
43
+ // Edited: Ferjolt Ozuni - Date: 2026-07-27
44
+ // FoundData.id is widened to (number | string) for external-provider
45
+ // offers (Mode A: search + redirect), but those never reach Step4 -
46
+ // Found/Route.tsx's CTA opens the provider's click-out link for them
47
+ // instead of calling onSave, so a real local numeric id is guaranteed here.
48
+ const busFrom = useBusOccupied(step1.departure, step2.id as number);
49
+ const busTo = useBusOccupied(step1._return, step3?.id as (number | undefined));
45
50
 
46
51
  if (busFrom === undefined || busTo === undefined) {
47
52
  return <Loading t={ t } />;
@@ -0,0 +1,87 @@
1
+ import { TFunction } from 'i18next';
2
+ import { RiWhatsappLine, RiTelegramLine } from 'react-icons/ri';
3
+ import { SubTitle } from '../../styles';
4
+ import { Container, Option, Notice } from './styles';
5
+
6
+ interface AddonsData {
7
+ whatsapp: { available: boolean, price: number }
8
+ telegram: { available: boolean, price: number }
9
+ }
10
+
11
+ interface Props {
12
+ data?: AddonsData
13
+ currency: string
14
+ whatsapp: boolean
15
+ whatsappNumber: string
16
+ telegram: boolean
17
+ t: TFunction<'common'>
18
+ onChangeWhatsapp: (value: boolean) => void
19
+ onChangeWhatsappNumber: (value: string) => void
20
+ onChangeTelegram: (value: boolean) => void
21
+ }
22
+
23
+ // Edited: Ferjolt Ozuni - Date: 2026-07-25
24
+ // Paid checkout addons - hidden entirely if the label has neither channel
25
+ // enabled (an empty box would be confusing). The WhatsApp addon collects
26
+ // the number here; Telegram is a "connect after purchase" flow (a bot
27
+ // can't message a bare phone number cold), so it's just a checkbox.
28
+ const Addons = ({ data, currency, whatsapp, whatsappNumber, telegram, t, onChangeWhatsapp, onChangeWhatsappNumber, onChangeTelegram }: Props): (JSX.Element | null) => {
29
+ if (!data || (!data.whatsapp.available && !data.telegram.available)) {
30
+ return null;
31
+ }
32
+
33
+ return (
34
+ <Container className="box">
35
+ <SubTitle>
36
+ { t('routes_order.step5.addons.title') }
37
+ </SubTitle>
38
+
39
+ { data.whatsapp.available && (
40
+ <Option>
41
+ <label>
42
+ <input
43
+ type="checkbox"
44
+ checked={ whatsapp }
45
+ onChange={ (event) => onChangeWhatsapp(event.target.checked) }
46
+ />
47
+
48
+ <RiWhatsappLine />
49
+
50
+ { t('routes_order.step5.addons.whatsapp.label') } (+{ data.whatsapp.price } { currency })
51
+ </label>
52
+
53
+ <Notice>{ t('routes_order.step5.addons.whatsapp.description') }</Notice>
54
+
55
+ { whatsapp && (
56
+ <input
57
+ type="text"
58
+ placeholder={ t('routes_order.step5.addons.whatsapp.phone_placeholder') }
59
+ value={ whatsappNumber }
60
+ onChange={ (event) => onChangeWhatsappNumber(event.target.value) }
61
+ />
62
+ ) }
63
+ </Option>
64
+ ) }
65
+
66
+ { data.telegram.available && (
67
+ <Option>
68
+ <label>
69
+ <input
70
+ type="checkbox"
71
+ checked={ telegram }
72
+ onChange={ (event) => onChangeTelegram(event.target.checked) }
73
+ />
74
+
75
+ <RiTelegramLine />
76
+
77
+ { t('routes_order.step5.addons.telegram.label') } (+{ data.telegram.price } { currency })
78
+ </label>
79
+
80
+ <Notice>{ t('routes_order.step5.addons.telegram.description') }</Notice>
81
+ </Option>
82
+ ) }
83
+ </Container>
84
+ );
85
+ };
86
+
87
+ export default Addons;
@@ -0,0 +1,32 @@
1
+ import styled from 'styled-components';
2
+
3
+ export const Container = styled.div`
4
+ margin-top: 25px;
5
+ `;
6
+
7
+ export const Option = styled.div`
8
+ margin-top: 15px;
9
+
10
+ &:first-child {
11
+ margin-top: 0;
12
+ }
13
+
14
+ label {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 6px;
18
+ cursor: pointer;
19
+ font-weight: 700;
20
+ }
21
+
22
+ input[type="text"] {
23
+ margin-top: 10px;
24
+ }
25
+ `;
26
+
27
+ export const Notice = styled.div`
28
+ margin-top: 4px;
29
+ margin-left: 22px;
30
+ font-size: ${ props => props.theme.size.xs };
31
+ color: ${ props => props.theme.font.faded };
32
+ `;
@@ -21,7 +21,12 @@ interface Props {
21
21
  const Coupons = ({ current, step2, t, total, onApplied }: Props): (JSX.Element | null) => {
22
22
  const { register, handleSubmit, formState: { errors } } = useForm<CouponForm>();
23
23
 
24
- const { mutate: SearchCoupons, isPending } = useGetCoupon(step2.id, total.value);
24
+ // Edited: Ferjolt Ozuni - Date: 2026-07-27
25
+ // step2.id is only a synthetic string for external-provider offers
26
+ // (Mode A: search + redirect), and those never reach Step5/checkout -
27
+ // Found/Route.tsx's CTA redirects to the provider instead of calling
28
+ // onSave, so a real local numeric id is guaranteed here.
29
+ const { mutate: SearchCoupons, isPending } = useGetCoupon(step2.id as number, total.value);
25
30
 
26
31
  const onSubmit = (data: CouponForm): void => {
27
32
  SearchCoupons(data, {
package/Step5/Step5.tsx CHANGED
@@ -9,6 +9,7 @@ import { Button } from '@autobusal/common';
9
9
  import Steps from '../Steps/Steps';
10
10
  import Summary from './Summary/Summary';
11
11
  import Coupons from './Coupons/Coupons';
12
+ import Addons from './Addons/Addons';
12
13
  import Billing from './Billing/Billing';
13
14
  import Payments from './Payments/Payments';
14
15
  import { getTotal, convertCoupon } from './utilities';
@@ -19,6 +20,7 @@ import { CouponData } from '@autobusal/providers/types/orders';
19
20
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
20
21
  import { TotalData } from '../types';
21
22
  import { useUserStore } from '@autobusal/providers/stores/user';
23
+ import { useGetSettings } from '@autobusal/providers/services';
22
24
  import { usePostOrder } from '../services';
23
25
 
24
26
  interface Props {
@@ -38,13 +40,46 @@ const Step5 = ({ step1, step2, step3, step4, isPartner, t, onBack }: Props): JSX
38
40
  );
39
41
  const [ action, setAction ] = useState<string | undefined>(undefined);
40
42
 
43
+ const [ addonWhatsapp, setAddonWhatsapp ] = useState<boolean>(false);
44
+ const [ addonWhatsappNumber, setAddonWhatsappNumber ] = useState<string>('');
45
+ const [ addonTelegram, setAddonTelegram ] = useState<boolean>(false);
46
+
41
47
  const { data: UserData } = useUserStore();
48
+ const { data: SettingsData } = useGetSettings();
42
49
 
43
50
  const navigate = useNavigate();
44
51
 
45
52
  const { register, handleSubmit, formState: { errors } } = useForm<BillingData>();
46
53
 
47
- const { mutate: OrderSend, isPending } = usePostOrder(step1, step2, step3, step4, coupon, action);
54
+ // Edited: Ferjolt Ozuni - Date: 2026-07-25
55
+ // Paid addons are priced/verified server-side (see obtapi's
56
+ // App\Libraries\Orders\Make\Addons) - this is only for the checkout
57
+ // summary display and the grand total shown before payment.
58
+ const currency = total.display.split(' ')[1] ?? '';
59
+
60
+ const addonItems = [
61
+ addonWhatsapp && SettingsData?.addons?.whatsapp.available ? {
62
+ label: t('routes_order.step5.addons.whatsapp.label'),
63
+ price: SettingsData.addons.whatsapp.price
64
+ } : null,
65
+ addonTelegram && SettingsData?.addons?.telegram.available ? {
66
+ label: t('routes_order.step5.addons.telegram.label'),
67
+ price: SettingsData.addons.telegram.price
68
+ } : null
69
+ ].filter((item): item is { label: string, price: number } => item !== null);
70
+
71
+ const addonsValue = addonItems.reduce((sum, item) => sum + item.price, 0);
72
+
73
+ const grandTotal: TotalData = {
74
+ display: `${ total.value + addonsValue } ${ currency }`,
75
+ value: total.value + addonsValue
76
+ };
77
+
78
+ const { mutate: OrderSend, isPending } = usePostOrder(step1, step2, step3, step4, coupon, action, {
79
+ whatsapp: addonWhatsapp,
80
+ whatsappNumber: addonWhatsappNumber,
81
+ telegram: addonTelegram
82
+ });
48
83
 
49
84
  const onApplyCoupon = (data: CouponData): void => {
50
85
  setTotal(
@@ -59,6 +94,12 @@ const Step5 = ({ step1, step2, step3, step4, isPartner, t, onBack }: Props): JSX
59
94
  }, []);
60
95
 
61
96
  const onSendForm = async (): Promise<void> => {
97
+ if (addonWhatsapp && addonWhatsappNumber.trim().length < 5) {
98
+ alert(t('routes_order.step5.addons.whatsapp.phone_error'));
99
+
100
+ return;
101
+ }
102
+
62
103
  await handleSubmit(onSubmit)();
63
104
  };
64
105
 
@@ -91,12 +132,25 @@ const Step5 = ({ step1, step2, step3, step4, isPartner, t, onBack }: Props): JSX
91
132
  step3={ step3 }
92
133
  step4={ step4 }
93
134
  coupon={ coupon }
94
- total={ total }
135
+ addons={ addonItems.map(item => ({ label: item.label, value: `${ item.price } ${ currency }` })) }
136
+ total={ grandTotal }
95
137
  t={ t }
96
138
  />
97
139
 
98
140
  <Coupons current={ coupon } step2={ step2 } total={ total } t={ t } onApplied={ onApplyCoupon } />
99
141
 
142
+ <Addons
143
+ data={ SettingsData?.addons }
144
+ currency={ currency }
145
+ whatsapp={ addonWhatsapp }
146
+ whatsappNumber={ addonWhatsappNumber }
147
+ telegram={ addonTelegram }
148
+ t={ t }
149
+ onChangeWhatsapp={ setAddonWhatsapp }
150
+ onChangeWhatsappNumber={ setAddonWhatsappNumber }
151
+ onChangeTelegram={ setAddonTelegram }
152
+ />
153
+
100
154
  { UserData === undefined && <Billing t={ t } errors={ errors } refs={ register } /> }
101
155
 
102
156
  <Payments
@@ -2,15 +2,16 @@ import { TFunction } from 'i18next';
2
2
  import { Container, Value, Code } from './styles';
3
3
 
4
4
  interface Props {
5
- type: 'total' | 'discount'
5
+ type: 'total' | 'discount' | 'addon'
6
6
  value: string
7
7
  code?: string
8
+ label?: string
8
9
  t: TFunction<'common'>
9
10
  }
10
11
 
11
- const Amount = ({ type, value, code, t }: Props): JSX.Element => (
12
+ const Amount = ({ type, value, code, label, t }: Props): JSX.Element => (
12
13
  <Container $type={ type }>
13
- { t(`routes_order.step5.summary.${ type }`) }
14
+ { label ?? t(`routes_order.step5.summary.${ type }`) }
14
15
 
15
16
  { code && <Code>{ code }</Code> }
16
17
 
@@ -1,6 +1,6 @@
1
1
  import styled, { css } from 'styled-components';
2
2
 
3
- export const Container = styled.div<{ $type: 'total' | 'discount' }>`
3
+ export const Container = styled.div<{ $type: 'total' | 'discount' | 'addon' }>`
4
4
  display: flex;
5
5
  align-items: center;
6
6
  padding: 10px 15px;
@@ -11,17 +11,23 @@ import { PersonData } from '@autobusal/providers/types/persons';
11
11
  import { CouponData } from '@autobusal/providers/types/orders';
12
12
  import { TotalData } from '../../types';
13
13
 
14
+ interface AddonItem {
15
+ label: string
16
+ value: string
17
+ }
18
+
14
19
  interface Props {
15
20
  step1: RoutesSearchForm
16
21
  step2: FoundData
17
22
  step3?: FoundData
18
23
  step4: PersonData
19
24
  coupon?: CouponData
25
+ addons?: AddonItem[]
20
26
  total: TotalData
21
27
  t: TFunction<'common'>
22
28
  }
23
29
 
24
- const Summary = ({ step1, step2, step3, step4, coupon, total, t }: Props): JSX.Element => (
30
+ const Summary = ({ step1, step2, step3, step4, coupon, addons, total, t }: Props): JSX.Element => (
25
31
  <div className="box">
26
32
  <SubTitle>
27
33
  <AiOutlineInfoCircle />
@@ -35,6 +41,10 @@ const Summary = ({ step1, step2, step3, step4, coupon, total, t }: Props): JSX.E
35
41
 
36
42
  <Tickets step1={ step1 } step4={ step4 } t={ t } />
37
43
 
44
+ { addons?.map(item => (
45
+ <Amount key={ item.label } type="addon" label={ item.label } value={ item.value } t={ t } />
46
+ )) }
47
+
38
48
  { coupon && <Amount type="discount" code={ coupon.code } value={ coupon.discount } t={ t } /> }
39
49
 
40
50
  <Amount type="total" value={ total.display } t={ t } />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/services.ts CHANGED
@@ -4,7 +4,7 @@ import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
4
  import { FoundData } from '@autobusal/providers/types/routes';
5
5
  import { CouponData } from '@autobusal/providers/types/orders';
6
6
  import { BillingData, PersonData } from '@autobusal/providers/types/persons';
7
- import { FoundDay, ActionData, SaveData, OrderedData, CouponForm } from './types';
7
+ import { FoundDay, ActionData, SaveData, OrderedData, CouponForm, AddonsSelection } from './types';
8
8
 
9
9
  export const useGetDepartures = (data: RoutesSearchForm): UseQueryResult<FoundData[]> => (
10
10
  useQuery({
@@ -74,7 +74,8 @@ export const usePostOrder = (
74
74
  step3: (FoundData | undefined),
75
75
  step4: PersonData,
76
76
  coupon: (CouponData | undefined),
77
- action: (string | undefined)
77
+ action: (string | undefined),
78
+ addons: AddonsSelection
78
79
  ): UseMutationResult<OrderedData, Error, BillingData, unknown> => (
79
80
  useMutation({
80
81
  mutationKey: ['save-order'],
@@ -95,6 +96,18 @@ export const usePostOrder = (
95
96
  order.coupon = coupon.code;
96
97
  }
97
98
 
99
+ // Edited: Ferjolt Ozuni - Date: 2026-07-25
100
+ // Paid addons - obtapi re-verifies availability/price server-side
101
+ // against the label's own settings, this is just the buyer's request
102
+ if (addons.whatsapp && addons.whatsappNumber) {
103
+ order.addon_whatsapp = '1';
104
+ order.addon_whatsapp_number = addons.whatsappNumber;
105
+ }
106
+
107
+ if (addons.telegram) {
108
+ order.addon_telegram = '1';
109
+ }
110
+
98
111
  Object.keys(step4).forEach(index => {
99
112
  order[index] = step4[index];
100
113
  });
package/types.ts CHANGED
@@ -33,4 +33,12 @@ export interface OrderedData {
33
33
  export interface TotalData {
34
34
  display: string
35
35
  value: number
36
+ }
37
+
38
+ // Edited: Ferjolt Ozuni - Date: 2026-07-25
39
+ // The buyer's paid-addon selection at checkout - see Step5/Addons/Addons.tsx
40
+ export interface AddonsSelection {
41
+ whatsapp: boolean
42
+ whatsappNumber: string
43
+ telegram: boolean
36
44
  }