@autobusal/routes-order 1.22.0 → 1.23.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
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.23.1
4
+
5
+ **Flexible Ticket as a title, a price and an Add button, with the detail collapsed.**
6
+
7
+ A button rather than a checkbox, because this is not the same kind of choice as the notification add-ons beside it — those are a cheap tick, this one changes what the ticket *is*. The toggle and the Add button are separate controls on purpose: reading the terms must never be a way to accidentally buy them.
8
+
9
+ One line stays **outside** the collapse — "without it, this ticket cannot be cancelled or changed". Everything else is detail somebody can choose to read, but that is the material fact, and hiding what a buyer gets for nothing behind a toggle is the shape of a dark pattern rather than a tidy layout.
10
+
11
+ ## 1.23.0
12
+
13
+ **Flexible Ticket at checkout.**
14
+
15
+ - 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.
16
+ - `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.
17
+ - `AddonsSelection` gains `flex`; the order payload gains `addon_flex`.
18
+ - `Found/Route` reads `routes_order.step2.route.information.*` after the `policies` rename.
19
+
20
+ Copy is **Flexible Ticket** / **Biletë Fleksibël**, never insurance — the distinction decides how the line is taxed and whether selling it needs a licence.
21
+
3
22
  All notable changes to this package are documented here. This project follows
4
23
  [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
5
24
 
@@ -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,9 @@
1
1
  import { TFunction } from 'i18next';
2
- import { RiWhatsappLine, RiTelegramLine } from 'react-icons/ri';
2
+ import { useState } from 'react';
3
+ import { RiWhatsappLine, RiTelegramLine, RiCalendarCheckLine, RiArrowUpSLine, RiArrowDownSLine } from 'react-icons/ri';
4
+ import { FlexOffer } from '@autobusal/providers/types/routes';
3
5
  import { SubTitle } from '../../styles';
4
- import { Container, Option, Notice } from './styles';
6
+ import { Container, Option, Notice, Terms, Term, FlexHeader, FlexToggle, FlexPrice, FlexAdd, FlexDetails } from './styles';
5
7
 
6
8
  interface AddonsData {
7
9
  whatsapp: { available: boolean, price: number }
@@ -10,14 +12,17 @@ interface AddonsData {
10
12
 
11
13
  interface Props {
12
14
  data?: AddonsData
15
+ flexOffer?: FlexOffer
13
16
  currency: string
14
17
  whatsapp: boolean
15
18
  whatsappNumber: string
16
19
  telegram: boolean
20
+ flex: boolean
17
21
  t: TFunction<'common'>
18
22
  onChangeWhatsapp: (value: boolean) => void
19
23
  onChangeWhatsappNumber: (value: string) => void
20
24
  onChangeTelegram: (value: boolean) => void
25
+ onChangeFlex: (value: boolean) => void
21
26
  }
22
27
 
23
28
  // Edited: Ferjolt Ozuni - Date: 2026-07-25
@@ -25,8 +30,22 @@ interface Props {
25
30
  // enabled (an empty box would be confusing). The WhatsApp addon collects
26
31
  // the number here; Telegram is a "connect after purchase" flow (a bot
27
32
  // 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)) {
33
+ const Addons = ({ data, flexOffer, currency, whatsapp, whatsappNumber, telegram, flex, t, onChangeWhatsapp, onChangeWhatsappNumber, onChangeTelegram, onChangeFlex }: Props): (JSX.Element | null) => {
34
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
35
+ // Flexible Ticket is only on offer where the operators behind the chosen
36
+ // routes have agreed to honour it, which is a per-booking answer the
37
+ // settings payload cannot give - hence the separate offer (see
38
+ // useGetFlex). When it is unavailable nothing renders for it at all,
39
+ // rather than a disabled row: a checkbox that unlocks nothing is worse
40
+ // than no checkbox.
41
+ const hasFlex = flexOffer?.available === true;
42
+
43
+ // Closed to begin with. The title, the price and the Add button are the
44
+ // decision; the windows and the small print are for somebody who wants to
45
+ // check before making it.
46
+ const [ open, setOpen ] = useState<boolean>(false);
47
+
48
+ if (!data || (!data.whatsapp.available && !data.telegram.available && !hasFlex)) {
30
49
  return null;
31
50
  }
32
51
 
@@ -80,6 +99,67 @@ const Addons = ({ data, currency, whatsapp, whatsappNumber, telegram, t, onChang
80
99
  <Notice>{ t('routes_order.step5.addons.telegram.description') }</Notice>
81
100
  </Option>
82
101
  ) }
102
+
103
+ { hasFlex && (
104
+ <Option>
105
+ <FlexHeader>
106
+ <FlexToggle type="button" aria-expanded={ open } onClick={ () => setOpen(!open) }>
107
+ <RiCalendarCheckLine />
108
+
109
+ { t('routes_order.step5.flex.title') }
110
+
111
+ <FlexPrice>{ flexOffer?.price_display }</FlexPrice>
112
+
113
+ { open ? <RiArrowUpSLine /> : <RiArrowDownSLine /> }
114
+ </FlexToggle>
115
+
116
+ <FlexAdd type="button" $added={ flex } onClick={ () => onChangeFlex(!flex) }>
117
+ { flex ? t('routes_order.step5.flex.remove') : t('routes_order.step5.flex.add') }
118
+ </FlexAdd>
119
+ </FlexHeader>
120
+
121
+ {/*
122
+ * Deliberately OUTSIDE the collapse. Everything else here is
123
+ * detail somebody can choose to read, but this one line is the
124
+ * material fact - a buyer is being asked to pay for a right they
125
+ * would otherwise not have, and hiding what they get for nothing
126
+ * behind a toggle is the shape of a dark pattern rather than a
127
+ * tidy layout.
128
+ */}
129
+ <Notice>{ t('routes_order.step5.flex.default') }</Notice>
130
+
131
+ { open && (
132
+ <FlexDetails>
133
+ <Terms>
134
+ { flexOffer?.refund_hours != null && (
135
+ <Term>{ t('routes_order.step5.flex.refund', { hours: flexOffer.refund_hours }) }</Term>
136
+ ) }
137
+
138
+ {/*
139
+ * Either half can be absent on its own. An operator who
140
+ * will move a booking but not refund it is a real case, and
141
+ * listing only what this booking actually includes is the
142
+ * difference between an offer and a misrepresentation.
143
+ */}
144
+ { flexOffer?.reschedule_hours != null && (
145
+ <Term>{ t('routes_order.step5.flex.reschedule', { hours: flexOffer.reschedule_hours }) }</Term>
146
+ ) }
147
+ </Terms>
148
+
149
+ <Notice>{ t('routes_order.step5.flex.nonrefundable') }</Notice>
150
+
151
+ {/*
152
+ * Statutory rights when the OPERATOR cancels are untouched by
153
+ * this and by anything else sold here. Saying so is both
154
+ * accurate and reassuring; leaving it out would imply the
155
+ * add-on is what grants them.
156
+ */}
157
+ <Notice>{ t('routes_order.step5.flex.carrier') }</Notice>
158
+ </FlexDetails>
159
+ ) }
160
+ </Option>
161
+ ) }
162
+
83
163
  </Container>
84
164
  );
85
165
  };
@@ -30,3 +30,82 @@ 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
+ `;
53
+
54
+ /**
55
+ * The Flexible Ticket row: what it is and its price on the left, the
56
+ * decision on the right.
57
+ *
58
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
59
+ *
60
+ * A button rather than a checkbox, because this is not the same kind of
61
+ * choice as the notification add-ons beside it - those are a cheap tick, this
62
+ * one changes what the ticket IS. The two halves are separate controls on
63
+ * purpose: reading the terms must never be a way to accidentally buy them.
64
+ */
65
+ export const FlexHeader = styled.div`
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: space-between;
69
+ gap: 10px;
70
+ `;
71
+
72
+ export const FlexToggle = styled.button`
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 6px;
76
+ padding: 0;
77
+ border: 0;
78
+ background: none;
79
+ color: inherit;
80
+ font-family: inherit;
81
+ font-size: inherit;
82
+ font-weight: 700;
83
+ cursor: pointer;
84
+ text-align: left;
85
+ `;
86
+
87
+ export const FlexPrice = styled.span`
88
+ font-weight: 700;
89
+ white-space: nowrap;
90
+ `;
91
+
92
+ export const FlexAdd = styled.button<{ $added: boolean }>`
93
+ flex: 0 0 auto;
94
+ padding: 6px 16px;
95
+ border-radius: 4px;
96
+ border: 1px solid ${ props => props.theme.primary.normal };
97
+ background: ${ props => props.$added ? 'transparent' : props.theme.primary.normal };
98
+ color: ${ props => props.$added ? props.theme.primary.normal : props.theme.primary.contrast };
99
+ font-family: inherit;
100
+ font-size: ${ props => props.theme.size.xs };
101
+ font-weight: 700;
102
+ cursor: pointer;
103
+
104
+ &:hover {
105
+ opacity: 0.85;
106
+ }
107
+ `;
108
+
109
+ export const FlexDetails = styled.div`
110
+ margin-left: 22px;
111
+ `;
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.1",
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
  }