@autobusal/routes-order 1.21.1 → 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,11 +1,41 @@
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
 
6
17
  > Note: 1.8.0 was published without a changelog entry. The gap is left as-is
7
18
  > rather than reconstructed after the fact.
8
19
 
20
+ ## 1.22.0
21
+
22
+ Structured data on route-pair pages: `BusTrip` with its operators and
23
+ fares-from, and a `FAQPage` answering the five things people actually search
24
+ for — how long, how much, how many a day, when the first and last leave, and
25
+ who runs it.
26
+
27
+ One rule governs all of it: **never state anything the page does not show.**
28
+ Every value is the same one rendered in the facts block or the timetable a few
29
+ hundred pixels below. A rich result built on a claim a visitor cannot find is
30
+ the mismatch that gets a site's snippets pulled, and it is also just a lie told
31
+ to a machine.
32
+
33
+ The rating hangs off the **provider**, not the trip. An operator's rating is
34
+ earned across everything they run; attaching it to one city pair would claim
35
+ travellers rated this journey when they rated the company. Operators below the
36
+ review display threshold carry no rating in the markup, exactly as they carry
37
+ none on the page.
38
+
9
39
  ## 1.21.1
10
40
 
11
41
  No horizontal scroll on a tablet's date strip. The 900px floor belongs to the
@@ -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 && (
package/Facts/types.ts CHANGED
@@ -6,6 +6,9 @@ export interface FactsData {
6
6
  // formatted by the API with the brand's own currency - the PUBLIC settings
7
7
  // payload carries no currency, so a frontend has nothing to format with
8
8
  price_from_display: string | null
9
+ // ISO 4217, for schema.org's Offer - the display string has the symbol
10
+ // baked in and guessing a code back out of it would be confidently wrong
11
+ currency: string | null
9
12
  // both ends of the range, in minutes
10
13
  duration_min: number | null
11
14
  duration_max: number | null
@@ -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
  };
@@ -0,0 +1,148 @@
1
+ import { TFunction } from 'i18next';
2
+ import { JsonLd } from '@autobusal/common';
3
+ import { FactsData } from '../Facts/types';
4
+
5
+ interface Props {
6
+ facts: FactsData
7
+ t: TFunction<'common'>
8
+ }
9
+
10
+ /**
11
+ * Structured data for a route-pair page.
12
+ *
13
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
14
+ *
15
+ * Roadmap Tier 3.5, and the cheapest item in the tier because the page
16
+ * already computes everything it needs.
17
+ *
18
+ * ONE RULE GOVERNS ALL OF IT: never state something here that the page does
19
+ * not show. A rich result built on a claim a visitor cannot find is the
20
+ * mismatch that gets a site's snippets pulled, and it is also just a lie
21
+ * told to a machine. Every value below is the same value rendered in the
22
+ * facts block or the timetable a few hundred pixels down - the duration
23
+ * range, the fares-from, the departure count, the operators.
24
+ *
25
+ * The rating hangs off the PROVIDER, not the trip. An operator's rating is
26
+ * earned across everything they run; attaching it to one city pair would be
27
+ * claiming travellers rated this journey when they rated the company. When
28
+ * an operator has no published rating - below the display threshold - it is
29
+ * simply absent, exactly as it is on the page.
30
+ */
31
+ const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
32
+ const schedule = facts.schedule ?? [];
33
+
34
+ // Deduplicated operators, each keeping whatever rating it actually has.
35
+ const providers = schedule.reduce<{ name: string, rating: { average: number, count: number } | null }[]>((all, row) => {
36
+ if (!row.operator || all.some(item => item.name === row.operator)) {
37
+ return all;
38
+ }
39
+
40
+ return [ ...all, { name: row.operator, rating: row.rating } ];
41
+ }, []);
42
+
43
+ const organisation = (name: string, rating: { average: number, count: number } | null) => ({
44
+ '@type': 'Organization',
45
+ name,
46
+ ...(rating ? {
47
+ aggregateRating: {
48
+ '@type': 'AggregateRating',
49
+ ratingValue: rating.average,
50
+ reviewCount: rating.count,
51
+ bestRating: 5,
52
+ worstRating: 1
53
+ }
54
+ } : {})
55
+ });
56
+
57
+ const trip = {
58
+ '@context': 'https://schema.org',
59
+ '@type': 'BusTrip',
60
+ name: t('routes_order.facts.title', { from: facts.from, to: facts.to }),
61
+
62
+ departureBusStop: { '@type': 'BusStop', name: facts.from },
63
+ arrivalBusStop: { '@type': 'BusStop', name: facts.to },
64
+
65
+ ...(providers.length > 0 ? {
66
+ provider: providers.map(item => organisation(item.name, item.rating))
67
+ } : {}),
68
+
69
+ // Only with a real ISO 4217 code. `price_from_display` is a formatted
70
+ // string with the symbol baked in, and guessing a code from it would
71
+ // produce structured data that is confidently wrong.
72
+ ...(facts.price_from !== null && facts.currency ? {
73
+ offers: {
74
+ '@type': 'Offer',
75
+ price: facts.price_from,
76
+ priceCurrency: facts.currency,
77
+ availability: 'https://schema.org/InStock'
78
+ }
79
+ } : {})
80
+ };
81
+
82
+ const questions: { q: string, a: string }[] = [];
83
+
84
+ const duration = (minutes: number): string => (
85
+ minutes % 60 === 0
86
+ ? t('routes_order.facts.duration_hours', { hours: minutes / 60 })
87
+ : t('routes_order.facts.duration_value', { hours: Math.floor(minutes / 60), minutes: minutes % 60 })
88
+ );
89
+
90
+ if (facts.duration_min !== null && facts.duration_max !== null) {
91
+ questions.push({
92
+ q: t('routes_order.schema.duration_q', { from: facts.from, to: facts.to }),
93
+ a: facts.duration_min === facts.duration_max
94
+ ? t('routes_order.schema.duration_a', { from: facts.from, to: facts.to, duration: duration(facts.duration_min) })
95
+ : t('routes_order.schema.duration_a_range', { from: facts.from, to: facts.to, min: duration(facts.duration_min), max: duration(facts.duration_max) })
96
+ });
97
+ }
98
+
99
+ if (facts.price_from_display) {
100
+ questions.push({
101
+ q: t('routes_order.schema.price_q', { from: facts.from, to: facts.to }),
102
+ a: t('routes_order.schema.price_a', { from: facts.from, to: facts.to, price: facts.price_from_display })
103
+ });
104
+ }
105
+
106
+ if (facts.departures > 0) {
107
+ questions.push({
108
+ q: t('routes_order.schema.departures_q', { from: facts.from, to: facts.to }),
109
+ a: t('routes_order.schema.departures_a', { count: facts.departures, from: facts.from, to: facts.to })
110
+ });
111
+ }
112
+
113
+ if (facts.departure_first && facts.departure_last) {
114
+ questions.push({
115
+ q: t('routes_order.schema.times_q', { from: facts.from, to: facts.to }),
116
+ a: t('routes_order.schema.times_a', { first: facts.departure_first, last: facts.departure_last })
117
+ });
118
+ }
119
+
120
+ if (facts.operators.length > 0) {
121
+ questions.push({
122
+ q: t('routes_order.schema.operators_q', { from: facts.from, to: facts.to }),
123
+ a: t('routes_order.facts.operators', { operators: facts.operators.join(', ') })
124
+ });
125
+ }
126
+
127
+ // Google requires a FAQPage to carry at least one question, and a page
128
+ // claiming to be an FAQ with nothing on it is worse than not claiming it.
129
+ const faq = questions.length > 0 ? {
130
+ '@context': 'https://schema.org',
131
+ '@type': 'FAQPage',
132
+ mainEntity: questions.map(item => ({
133
+ '@type': 'Question',
134
+ name: item.q,
135
+ acceptedAnswer: { '@type': 'Answer', text: item.a }
136
+ }))
137
+ } : null;
138
+
139
+ return (
140
+ <>
141
+ <JsonLd data={ trip } />
142
+
143
+ { faq && <JsonLd data={ faq } /> }
144
+ </>
145
+ );
146
+ };
147
+
148
+ export default Schema;
@@ -2,6 +2,7 @@ import { TFunction } from 'i18next';
2
2
  import Facts from '../Facts/Facts';
3
3
  import Schedule from '../Schedule/Schedule';
4
4
  import Links from '../Links/Links';
5
+ import Schema from './Schema';
5
6
  import { useGetFacts } from '../Facts/services';
6
7
  import { Nav, Anchor, Anchored } from './styles';
7
8
 
@@ -64,6 +65,11 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
64
65
  ) }
65
66
  </Nav>
66
67
 
68
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
69
+ Roadmap Tier 3.5. Emits nothing the sections below do not also
70
+ show - same facts, same numbers, same operators. */ }
71
+ <Schema facts={ facts } t={ t } />
72
+
67
73
  <Anchored>
68
74
  <Facts id={ FACTS } facts={ facts } t={ t } />
69
75
  </Anchored>
@@ -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.21.1",
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
  }