@autobusal/routes-order 1.22.0 → 1.23.0

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.23.0
4
+
5
+ **Flexible Ticket at checkout.**
6
+
7
+ - New `useGetFlex(departurePriceId, returnPriceId, adults, children)`. Its own request rather than a field on the search result, because the answer depends on **both legs at once**: an operator who will not amend one half removes the add-on from the whole booking, and the price is computed on the combined fare.
8
+ - `Addons` renders the option below passenger details, where it already sat. It shows the **default first** — "without it, this ticket cannot be cancelled or changed" — then only the capabilities this booking actually includes, then that the fee itself is not refunded, then that statutory rights when the *operator* cancels are untouched. Nothing renders at all when the offer is unavailable: a checkbox that unlocks nothing is worse than no checkbox.
9
+ - `AddonsSelection` gains `flex`; the order payload gains `addon_flex`.
10
+ - `Found/Route` reads `routes_order.step2.route.information.*` after the `policies` rename.
11
+
12
+ Copy is **Flexible Ticket** / **Biletë Fleksibël**, never insurance — the distinction decides how the line is taxed and whether selling it needs a licence.
13
+
3
14
  All notable changes to this package are documented here. This project follows
4
15
  [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
5
16
 
@@ -24,7 +24,7 @@ import { CouponData } from '@autobusal/providers/types/orders';
24
24
  import { TotalData } from '../types';
25
25
  import { useUserStore } from '@autobusal/providers/stores/user';
26
26
  import { useGetSettings } from '@autobusal/providers/services';
27
- import { usePostOrder } from '../services';
27
+ import { usePostOrder, useGetFlex } from '../services';
28
28
 
29
29
  interface Props {
30
30
  values?: PersonData
@@ -75,6 +75,7 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
75
75
  const [ addonWhatsapp, setAddonWhatsapp ] = useState<boolean>(false);
76
76
  const [ addonWhatsappNumber, setAddonWhatsappNumber ] = useState<string>('');
77
77
  const [ addonTelegram, setAddonTelegram ] = useState<boolean>(false);
78
+ const [ addonFlex, setAddonFlex ] = useState<boolean>(false);
78
79
 
79
80
  const { data: UserData } = useUserStore();
80
81
  const { data: SettingsData } = useGetSettings();
@@ -93,6 +94,19 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
93
94
 
94
95
  const currency = total.display.split(' ')[1] ?? '';
95
96
 
97
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
98
+ // Flexible Ticket, for the routes actually chosen. Asked for BOTH legs at
99
+ // once: the price is computed on the combined fare and an operator who
100
+ // will not amend one half removes the offer from the whole booking, so a
101
+ // per-leg question could not be answered honestly.
102
+ const { data: FlexData } = useGetFlex(
103
+ step2.price.id as number,
104
+ step3?.price.id as (number | undefined),
105
+ step1.adults ?? 1,
106
+ step1.children ?? 0,
107
+ step1.babies ?? 0
108
+ );
109
+
96
110
  const addonItems = [
97
111
  addonWhatsapp && SettingsData?.addons?.whatsapp.available ? {
98
112
  label: t('routes_order.step5.addons.whatsapp.label'),
@@ -101,6 +115,10 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
101
115
  addonTelegram && SettingsData?.addons?.telegram.available ? {
102
116
  label: t('routes_order.step5.addons.telegram.label'),
103
117
  price: SettingsData.addons.telegram.price
118
+ } : null,
119
+ addonFlex && FlexData?.available && FlexData.price !== undefined ? {
120
+ label: t('routes_order.step5.flex.title'),
121
+ price: FlexData.price
104
122
  } : null
105
123
  ].filter((item): item is { label: string, price: number } => item !== null);
106
124
 
@@ -114,7 +132,8 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
114
132
  const { mutate: OrderSend, isPending } = usePostOrder(step1, step2, step3, coupon, action, {
115
133
  whatsapp: addonWhatsapp,
116
134
  whatsappNumber: addonWhatsappNumber,
117
- telegram: addonTelegram
135
+ telegram: addonTelegram,
136
+ flex: addonFlex
118
137
  });
119
138
 
120
139
  const onApplyCoupon = (data: CouponData): void => {
@@ -215,14 +234,17 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
215
234
 
216
235
  <Addons
217
236
  data={ SettingsData?.addons }
237
+ flexOffer={ FlexData }
218
238
  currency={ currency }
219
239
  whatsapp={ addonWhatsapp }
220
240
  whatsappNumber={ addonWhatsappNumber }
221
241
  telegram={ addonTelegram }
242
+ flex={ addonFlex }
222
243
  t={ t }
223
244
  onChangeWhatsapp={ setAddonWhatsapp }
224
245
  onChangeWhatsappNumber={ setAddonWhatsappNumber }
225
246
  onChangeTelegram={ setAddonTelegram }
247
+ onChangeFlex={ setAddonFlex }
226
248
  />
227
249
 
228
250
  { UserData === undefined && (
@@ -4,7 +4,7 @@ import { HiOutlineArrowNarrowRight, HiOutlineDocumentText, HiChevronDown, HiChev
4
4
  import { BiHourglass } from 'react-icons/bi';
5
5
  import { RiBus2Line } from 'react-icons/ri';
6
6
  import { formatDuration, splitPrice } from '../refine';
7
- import { Button, RouteFeature, Policy, Rating } from '@autobusal/common';
7
+ import { Button, RouteFeature, Information, Rating } from '@autobusal/common';
8
8
  import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, Details, Detail, ButtonPolicy, TitleDetail, LinkRoute, FeaturesItems, Leaves } from './styles';
9
9
  import { FoundData } from '@autobusal/providers/types/routes';
10
10
 
@@ -25,7 +25,7 @@ interface Props {
25
25
  }
26
26
 
27
27
  const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Element => {
28
- const [ policy, setPolicy ] = useState<boolean>(false);
28
+ const [ information, setInformation ] = useState<boolean>(false);
29
29
 
30
30
  // Edited: Ferjolt Ozuni - Date: 2026-08-03
31
31
  // Closed to begin with. Amenities, route code, transit and policies are
@@ -203,17 +203,17 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
203
203
 
204
204
  { !data.external && (
205
205
  <Detail>
206
- <TitleDetail>{ t('routes_order.step2.route.policies.title') }</TitleDetail>
206
+ <TitleDetail>{ t('routes_order.step2.route.information.title') }</TitleDetail>
207
207
 
208
- <ButtonPolicy type="button" onClick={ () => setPolicy(true) }>
208
+ <ButtonPolicy type="button" onClick={ () => setInformation(true) }>
209
209
  <HiOutlineDocumentText />
210
- { t('routes_order.step2.route.policies.view') }
210
+ { t('routes_order.step2.route.information.view') }
211
211
  </ButtonPolicy>
212
212
  </Detail>
213
213
  ) }
214
214
  </Details>
215
215
 
216
- { policy && typeof data.id === 'number' && <Policy id={ data.id } t={ t } onClose={ () => setPolicy(false) } /> }
216
+ { information && typeof data.id === 'number' && <Information id={ data.id } t={ t } onClose={ () => setInformation(false) } /> }
217
217
  </Container>
218
218
  );
219
219
  };
@@ -1,7 +1,8 @@
1
1
  import { TFunction } from 'i18next';
2
- import { RiWhatsappLine, RiTelegramLine } from 'react-icons/ri';
2
+ import { RiWhatsappLine, RiTelegramLine, RiCalendarCheckLine } from 'react-icons/ri';
3
+ import { FlexOffer } from '@autobusal/providers/types/routes';
3
4
  import { SubTitle } from '../../styles';
4
- import { Container, Option, Notice } from './styles';
5
+ import { Container, Option, Notice, Terms, Term } from './styles';
5
6
 
6
7
  interface AddonsData {
7
8
  whatsapp: { available: boolean, price: number }
@@ -10,14 +11,17 @@ interface AddonsData {
10
11
 
11
12
  interface Props {
12
13
  data?: AddonsData
14
+ flexOffer?: FlexOffer
13
15
  currency: string
14
16
  whatsapp: boolean
15
17
  whatsappNumber: string
16
18
  telegram: boolean
19
+ flex: boolean
17
20
  t: TFunction<'common'>
18
21
  onChangeWhatsapp: (value: boolean) => void
19
22
  onChangeWhatsappNumber: (value: string) => void
20
23
  onChangeTelegram: (value: boolean) => void
24
+ onChangeFlex: (value: boolean) => void
21
25
  }
22
26
 
23
27
  // Edited: Ferjolt Ozuni - Date: 2026-07-25
@@ -25,8 +29,17 @@ interface Props {
25
29
  // enabled (an empty box would be confusing). The WhatsApp addon collects
26
30
  // the number here; Telegram is a "connect after purchase" flow (a bot
27
31
  // 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)) {
32
+ const Addons = ({ data, flexOffer, currency, whatsapp, whatsappNumber, telegram, flex, t, onChangeWhatsapp, onChangeWhatsappNumber, onChangeTelegram, onChangeFlex }: Props): (JSX.Element | null) => {
33
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
34
+ // Flexible Ticket is only on offer where the operators behind the chosen
35
+ // routes have agreed to honour it, which is a per-booking answer the
36
+ // settings payload cannot give - hence the separate offer (see
37
+ // useGetFlex). When it is unavailable nothing renders for it at all,
38
+ // rather than a disabled row: a checkbox that unlocks nothing is worse
39
+ // than no checkbox.
40
+ const hasFlex = flexOffer?.available === true;
41
+
42
+ if (!data || (!data.whatsapp.available && !data.telegram.available && !hasFlex)) {
30
43
  return null;
31
44
  }
32
45
 
@@ -80,6 +93,56 @@ const Addons = ({ data, currency, whatsapp, whatsappNumber, telegram, t, onChang
80
93
  <Notice>{ t('routes_order.step5.addons.telegram.description') }</Notice>
81
94
  </Option>
82
95
  ) }
96
+
97
+ { hasFlex && (
98
+ <Option>
99
+ <label>
100
+ <input
101
+ type="checkbox"
102
+ checked={ flex }
103
+ onChange={ (event) => onChangeFlex(event.target.checked) }
104
+ />
105
+
106
+ <RiCalendarCheckLine />
107
+
108
+ { t('routes_order.step5.flex.add', { price: flexOffer?.price_display }) }
109
+ </label>
110
+
111
+ {/*
112
+ * The default is stated FIRST and plainly. The passenger is being
113
+ * asked to pay for flexibility, so what they get without paying
114
+ * has to be the thing they read before the price, not a
115
+ * disclosure buried under it.
116
+ */}
117
+ <Notice>{ t('routes_order.step5.flex.default') }</Notice>
118
+
119
+ <Terms>
120
+ { flexOffer?.refund_hours != null && (
121
+ <Term>{ t('routes_order.step5.flex.refund', { hours: flexOffer.refund_hours }) }</Term>
122
+ ) }
123
+
124
+ {/*
125
+ * Either half can be absent on its own. An operator who will
126
+ * move a booking but not refund it is a real case, and listing
127
+ * only what this booking actually includes is the difference
128
+ * between an offer and a misrepresentation.
129
+ */}
130
+ { flexOffer?.reschedule_hours != null && (
131
+ <Term>{ t('routes_order.step5.flex.reschedule', { hours: flexOffer.reschedule_hours }) }</Term>
132
+ ) }
133
+ </Terms>
134
+
135
+ <Notice>{ t('routes_order.step5.flex.nonrefundable') }</Notice>
136
+
137
+ {/*
138
+ * Statutory rights when the OPERATOR cancels are untouched by
139
+ * this and by anything else sold here. Saying so at the point of
140
+ * sale is both accurate and reassuring; leaving it out would
141
+ * imply the add-on is what grants them.
142
+ */}
143
+ <Notice>{ t('routes_order.step5.flex.carrier') }</Notice>
144
+ </Option>
145
+ ) }
83
146
  </Container>
84
147
  );
85
148
  };
@@ -30,3 +30,23 @@ export const Notice = styled.div`
30
30
  font-size: ${ props => props.theme.size.xs };
31
31
  color: ${ props => props.theme.font.faded };
32
32
  `;
33
+
34
+ /**
35
+ * What the add-on actually unlocks, as a short list.
36
+ *
37
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
38
+ * A list rather than a sentence because the two windows can differ, and
39
+ * because either can be absent - prose would have to be rewritten for each
40
+ * combination, a list simply gets shorter.
41
+ */
42
+ export const Terms = styled.ul`
43
+ margin: 8px 0 10px;
44
+ padding: 0 0 0 18px;
45
+ list-style: disc;
46
+ `;
47
+
48
+ export const Term = styled.li`
49
+ margin: 2px 0;
50
+ font-size: 14px;
51
+ line-height: 1.5;
52
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/services.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useMutation, UseMutationResult, useQuery, UseQueryResult } from '@tanstack/react-query';
2
2
  import { apiClient } from '@autobusal/providers';
3
3
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
- import { FoundData } from '@autobusal/providers/types/routes';
4
+ import { FoundData, FlexOffer } 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
7
  import { FoundDay, ActionData, SaveData, OrderedData, CouponForm, AddonsSelection, AlternativesData } from './types';
@@ -156,6 +156,10 @@ export const usePostOrder = (
156
156
  order.addon_telegram = '1';
157
157
  }
158
158
 
159
+ if (addons.flex) {
160
+ order.addon_flex = '1';
161
+ }
162
+
159
163
  Object.keys(step4).forEach(index => {
160
164
  order[index] = step4[index];
161
165
  });
@@ -207,4 +211,44 @@ export const useGetPayments = (): UseQueryResult<ActionData> => (
207
211
  ))
208
212
  )
209
213
  })
210
- );
214
+ );
215
+
216
+ /**
217
+ * The Flexible Ticket offer for the routes actually chosen.
218
+ *
219
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
220
+ *
221
+ * Its own request rather than a field on the search result, because the
222
+ * answer depends on BOTH legs of a return trip at once: an operator who will
223
+ * not amend one half removes the add-on from the whole booking, and the
224
+ * price is computed on the combined fare. A per-route field could not say
225
+ * either of those things.
226
+ *
227
+ * Disabled until there is a departure price to ask about, so it never fires
228
+ * on a half-built booking.
229
+ */
230
+ export const useGetFlex = (
231
+ departurePriceId: (number | undefined),
232
+ returnPriceId: (number | undefined),
233
+ adults: number,
234
+ children: number,
235
+ babies: number
236
+ ): UseQueryResult<FlexOffer> => (
237
+ useQuery({
238
+ queryKey: ['flex-offer', { departurePriceId, returnPriceId, adults, children, babies }],
239
+ enabled: departurePriceId !== undefined,
240
+ queryFn: async () => (
241
+ await apiClient
242
+ .get('/api/orders/flex', {
243
+ params: {
244
+ departure_price_id: departurePriceId,
245
+ return_price_id: returnPriceId,
246
+ adults,
247
+ children,
248
+ babies
249
+ }
250
+ })
251
+ .then(response => response.data)
252
+ )
253
+ })
254
+ );
package/types.ts CHANGED
@@ -71,4 +71,10 @@ export interface AddonsSelection {
71
71
  whatsapp: boolean
72
72
  whatsappNumber: string
73
73
  telegram: boolean
74
+
75
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
76
+ // Flexible Ticket. A bare boolean, like Telegram - the window and the
77
+ // price are the server's to decide (Libraries\Orders\Flex), and a client
78
+ // that sent either would be ignored.
79
+ flex: boolean
74
80
  }