@autobusal/routes-order 1.8.0 → 1.9.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 +731 -0
- package/Checkout/Checkout.tsx +519 -0
- package/Checkout/Sidebar/Sidebar.tsx +124 -0
- package/Checkout/Sidebar/styles.ts +52 -0
- package/Facts/Facts.tsx +271 -0
- package/Facts/questions.ts +250 -0
- package/Facts/service.ts +111 -0
- package/Facts/services.ts +19 -0
- package/Facts/styles.ts +112 -0
- package/Facts/types.ts +167 -0
- package/Faq/Faq.tsx +52 -0
- package/Faq/styles.ts +68 -0
- package/Found/Dates/Dates.tsx +95 -5
- package/Found/Dates/styles.ts +173 -9
- package/Found/Empty/Empty.tsx +136 -0
- package/Found/Empty/styles.ts +98 -0
- package/Found/Filters/Filters.tsx +194 -0
- package/Found/Filters/styles.ts +188 -0
- package/Found/Found.tsx +207 -17
- package/Found/Group/Group.tsx +68 -0
- package/Found/Group/styles.ts +44 -0
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +89 -27
- package/Found/Orders/styles.ts +14 -0
- package/Found/Picks/Picks.tsx +75 -0
- package/Found/Picks/styles.ts +106 -0
- package/Found/Route/Route.tsx +211 -33
- package/Found/Route/styles.ts +491 -46
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/Sort.tsx +40 -0
- package/Found/Sort/styles.ts +87 -0
- package/Found/Summary/Summary.tsx +49 -0
- package/Found/Summary/styles.ts +42 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/Found/refine.ts +652 -0
- package/Links/Links.tsx +91 -0
- package/Links/styles.ts +75 -0
- package/RoutesOrder.tsx +239 -31
- package/Schedule/Schedule.tsx +123 -0
- package/Schedule/styles.ts +71 -0
- package/Sections/Schema.tsx +186 -0
- package/Sections/Sections.tsx +160 -0
- package/Sections/styles.ts +44 -0
- package/Step1/prepare.ts +21 -2
- package/Step2.tsx +34 -1
- package/Step4/Passenger/Passenger.tsx +253 -24
- package/Step4/Passenger/styles.ts +21 -1
- package/Step4/Passengers/Display.tsx +10 -2
- package/Step4/Passengers/Passengers.tsx +53 -4
- package/Step4/Saved/Choose.tsx +99 -0
- package/Step4/Saved/Remember.tsx +58 -0
- package/Step4/Saved/styles.ts +87 -0
- package/Step4/checkAge.ts +15 -4
- package/Step5/Addons/Addons.tsx +146 -4
- package/Step5/Addons/styles.ts +94 -0
- package/Step5/Billing/Billing.tsx +138 -70
- package/Step5/Coupons/Coupons.tsx +28 -18
- package/Step5/Coupons/styles.ts +41 -8
- package/Step5/Payments/Payments.tsx +36 -5
- package/Step5/Payments/styles.ts +16 -7
- package/Step5/Summary/About/About.tsx +99 -27
- package/Step5/Summary/About/styles.ts +118 -1
- package/Step5/Summary/styles.ts +12 -5
- package/Step5/utilities.ts +67 -12
- package/Steps/Steps.tsx +3 -1
- package/commerce.ts +24 -0
- package/package.json +1 -1
- package/services.ts +302 -13
- package/styles.ts +126 -0
- package/types.ts +84 -1
- package/Found/Booking/Booking.tsx +0 -61
- package/Found/Booking/styles.ts +0 -50
- package/Step4/Step4.tsx +0 -114
- package/Step5/Step5.tsx +0 -185
package/services.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useMutation, UseMutationResult, useQuery, UseQueryResult } from '@tanstack/react-query';
|
|
2
|
-
import {
|
|
2
|
+
import { AxiosError } from 'axios';
|
|
3
|
+
import { apiClient, storedReferral } from '@autobusal/providers';
|
|
3
4
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
4
|
-
import { FoundData } from '@autobusal/providers/types/routes';
|
|
5
|
+
import { FoundData, FlexOffer } from '@autobusal/providers/types/routes';
|
|
5
6
|
import { CouponData } from '@autobusal/providers/types/orders';
|
|
6
|
-
import { BillingData, PersonData } from '@autobusal/providers/types/persons';
|
|
7
|
-
import { FoundDay, ActionData, SaveData, OrderedData, CouponForm, AddonsSelection } from './types';
|
|
7
|
+
import { BillingData, PersonData, SavedPassengerRecord, SavedPassengersData } from '@autobusal/providers/types/persons';
|
|
8
|
+
import { FoundDay, ActionData, SaveData, OrderedData, CouponForm, AddonsSelection, AlternativesData, PriceAlertRequest, PriceAlertData } from './types';
|
|
8
9
|
|
|
9
10
|
export const useGetDepartures = (data: RoutesSearchForm): UseQueryResult<FoundData[]> => (
|
|
10
11
|
useQuery({
|
|
@@ -21,6 +22,44 @@ export const useGetDepartures = (data: RoutesSearchForm): UseQueryResult<FoundDa
|
|
|
21
22
|
})
|
|
22
23
|
);
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* What to offer when a search finds nothing.
|
|
27
|
+
*
|
|
28
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
29
|
+
*
|
|
30
|
+
* `enabled` is the whole point: this only runs once the departure search
|
|
31
|
+
* has come back empty, so a search that succeeded never pays for it. On
|
|
32
|
+
* obtapi's side it costs a handful of real searches, which is why it is
|
|
33
|
+
* not simply folded into /departure.
|
|
34
|
+
*/
|
|
35
|
+
export const useGetAlternatives = (data: RoutesSearchForm, enabled: boolean): UseQueryResult<AlternativesData> => (
|
|
36
|
+
useQuery({
|
|
37
|
+
queryKey: ['search-alternatives', { data }],
|
|
38
|
+
enabled,
|
|
39
|
+
queryFn: async () => (
|
|
40
|
+
await apiClient
|
|
41
|
+
.get('/api/routes/search/alternatives', {
|
|
42
|
+
params: data
|
|
43
|
+
})
|
|
44
|
+
.then(response => (
|
|
45
|
+
response?.data ?? { from: [], to: [], dates: [] }
|
|
46
|
+
))
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The return legs on offer for an already-chosen outbound.
|
|
53
|
+
*
|
|
54
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
55
|
+
* The return date goes over as `return`. The form calls it `_return`
|
|
56
|
+
* because `return` is a reserved word in JS, and spreading the form
|
|
57
|
+
* straight into the query string therefore sent a field the API does not
|
|
58
|
+
* know while omitting the one it validates - so this request answered 422
|
|
59
|
+
* "The return field is required" every single time, and step 3 could never
|
|
60
|
+
* list anything. The order builder below has always done this mapping
|
|
61
|
+
* (`order.return = step1._return`); only this call was missed.
|
|
62
|
+
*/
|
|
24
63
|
export const useGetReturns = (data: RoutesSearchForm, departure: FoundData): UseQueryResult<FoundData[]> => (
|
|
25
64
|
useQuery({
|
|
26
65
|
queryKey: ['search-routes-step3', { data }],
|
|
@@ -29,6 +68,7 @@ export const useGetReturns = (data: RoutesSearchForm, departure: FoundData): Use
|
|
|
29
68
|
.get('/api/routes/search/return', {
|
|
30
69
|
params: {
|
|
31
70
|
...data,
|
|
71
|
+
return: data._return,
|
|
32
72
|
operator_id: departure.operator.id
|
|
33
73
|
}
|
|
34
74
|
})
|
|
@@ -52,13 +92,23 @@ export const useGetDates = (step1: RoutesSearchForm, type: ('departure' | '_retu
|
|
|
52
92
|
}
|
|
53
93
|
|
|
54
94
|
return useQuery({
|
|
55
|
-
|
|
95
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
96
|
+
// The key was ['step2-dates', { number }] - the search itself was not in
|
|
97
|
+
// it, so every city pair and passenger mix shared one cache entry and
|
|
98
|
+
// the strip kept showing the previous trip's days. Invisible while the
|
|
99
|
+
// strip was only booleans; with per-day prices on it, it would have
|
|
100
|
+
// quoted one route's fares under another route's dates.
|
|
101
|
+
queryKey: ['step2-dates', { step, number }],
|
|
56
102
|
queryFn: async () => (
|
|
57
103
|
await apiClient
|
|
58
104
|
.get('/api/routes/search/dates', {
|
|
59
105
|
params: {
|
|
60
106
|
...step,
|
|
61
|
-
number
|
|
107
|
+
number,
|
|
108
|
+
// which half of a round trip this strip prices (BOOK-02-b):
|
|
109
|
+
// the from/to swap above handles the direction, this handles
|
|
110
|
+
// the FARE - a return leg sells at adult_return
|
|
111
|
+
leg: type === '_return' ? 'return' : 'departure'
|
|
62
112
|
}
|
|
63
113
|
})
|
|
64
114
|
.then(response => (
|
|
@@ -68,18 +118,34 @@ export const useGetDates = (step1: RoutesSearchForm, type: ('departure' | '_retu
|
|
|
68
118
|
})
|
|
69
119
|
};
|
|
70
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
123
|
+
*
|
|
124
|
+
* The passenger details moved from a hook argument into the mutation
|
|
125
|
+
* variables. On the merged checkout screen they are only known at the
|
|
126
|
+
* moment the buyer presses pay - validated right then, in the same handler
|
|
127
|
+
* - so baking them into the hook's closure at render time would have sent
|
|
128
|
+
* whatever was in state one render earlier.
|
|
129
|
+
*/
|
|
130
|
+
export interface OrderSubmission {
|
|
131
|
+
billing: BillingData
|
|
132
|
+
person: PersonData
|
|
133
|
+
}
|
|
134
|
+
|
|
71
135
|
export const usePostOrder = (
|
|
72
136
|
step1: RoutesSearchForm,
|
|
73
137
|
step2: FoundData,
|
|
74
138
|
step3: (FoundData | undefined),
|
|
75
|
-
step4: PersonData,
|
|
76
139
|
coupon: (CouponData | undefined),
|
|
77
140
|
action: (string | undefined),
|
|
78
141
|
addons: AddonsSelection
|
|
79
|
-
): UseMutationResult<OrderedData, Error,
|
|
142
|
+
): UseMutationResult<OrderedData, Error, OrderSubmission, unknown> => (
|
|
80
143
|
useMutation({
|
|
81
144
|
mutationKey: ['save-order'],
|
|
82
|
-
mutationFn: async (
|
|
145
|
+
mutationFn: async ({ billing, person }: OrderSubmission) => {
|
|
146
|
+
const data = billing;
|
|
147
|
+
const step4 = person;
|
|
148
|
+
|
|
83
149
|
const type = step1.type ?? 'departure';
|
|
84
150
|
|
|
85
151
|
const order: SaveData = {};
|
|
@@ -108,14 +174,71 @@ export const usePostOrder = (
|
|
|
108
174
|
order.addon_telegram = '1';
|
|
109
175
|
}
|
|
110
176
|
|
|
177
|
+
// The number may be blank here: obtapi falls back to the first
|
|
178
|
+
// passenger phone the form already collected (Orders\Make\Addons),
|
|
179
|
+
// so ticking the box on a route that asks for a phone is enough.
|
|
180
|
+
if (addons.sms) {
|
|
181
|
+
order.addon_sms = '1';
|
|
182
|
+
order.addon_sms_number = addons.smsNumber;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (addons.flex) {
|
|
186
|
+
order.addon_flex = '1';
|
|
187
|
+
}
|
|
188
|
+
|
|
111
189
|
Object.keys(step4).forEach(index => {
|
|
112
190
|
order[index] = step4[index];
|
|
113
191
|
});
|
|
114
192
|
|
|
193
|
+
/*
|
|
194
|
+
* Edited: Claude - Date: 2026-08-26
|
|
195
|
+
*
|
|
196
|
+
* Seat fields. A zero means "no choice made": with the seat-selection
|
|
197
|
+
* add-on the picker is optional and 0 is its resting value, and the
|
|
198
|
+
* API auto-assigns ABSENT fields but rejects a literal zero as a
|
|
199
|
+
* taken seat - so unpicked fields must not travel at all. Stripping
|
|
200
|
+
* unconditionally is safe for the legacy mode too: there the fields
|
|
201
|
+
* are validated required|min:1, so a zero cannot reach this line.
|
|
202
|
+
*
|
|
203
|
+
* addon_seats goes over only when the brand sells the add-on AND at
|
|
204
|
+
* least one hand-picked seat survived the strip - the flag is the
|
|
205
|
+
* buyer's opt-in, the server's Buses\Assign counts what it charges.
|
|
206
|
+
*/
|
|
207
|
+
let chosenSeats = 0;
|
|
208
|
+
|
|
209
|
+
Object.keys(order).forEach(key => {
|
|
210
|
+
if (/_s[dr]$/.test(key)) {
|
|
211
|
+
if (Number(order[key]) > 0) {
|
|
212
|
+
chosenSeats++;
|
|
213
|
+
} else {
|
|
214
|
+
delete order[key];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (addons.seats && chosenSeats > 0) {
|
|
220
|
+
order.addon_seats = '1';
|
|
221
|
+
}
|
|
222
|
+
|
|
115
223
|
if (action !== undefined) {
|
|
116
224
|
order.action = action;
|
|
117
225
|
}
|
|
118
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
|
+
|
|
119
242
|
if (data !== undefined) {
|
|
120
243
|
Object.keys(data).forEach(index => {
|
|
121
244
|
order[index] = data[index as keyof typeof data];
|
|
@@ -148,15 +271,181 @@ export const useGetCoupon = (routeId: number, total: number): UseMutationResult<
|
|
|
148
271
|
})
|
|
149
272
|
);
|
|
150
273
|
|
|
151
|
-
|
|
274
|
+
/*
|
|
275
|
+
* Claude - 2026-08-22 (audit decision BOOK-07): asks with the route being
|
|
276
|
+
* sold, so the API can offer Complimentary to an operator/employee on
|
|
277
|
+
* their own operator's route. External routes carry string ids and can
|
|
278
|
+
* never be own inventory, so those simply don't send the parameter.
|
|
279
|
+
*/
|
|
280
|
+
export const useGetPayments = (routeId?: number | string): UseQueryResult<ActionData> => (
|
|
281
|
+
useQuery({
|
|
282
|
+
queryKey: ['allowed-funds', { routeId }],
|
|
283
|
+
queryFn: async () => (
|
|
284
|
+
await apiClient
|
|
285
|
+
.get('/api/funds/allowed', {
|
|
286
|
+
params: typeof routeId === 'number' ? { route: routeId } : {}
|
|
287
|
+
})
|
|
288
|
+
.then(response => (
|
|
289
|
+
response.data
|
|
290
|
+
))
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The Flexible Ticket offer for the routes actually chosen.
|
|
297
|
+
*
|
|
298
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
299
|
+
*
|
|
300
|
+
* Its own request rather than a field on the search result, because the
|
|
301
|
+
* answer depends on BOTH legs of a return trip at once: an operator who will
|
|
302
|
+
* not amend one half removes the add-on from the whole booking, and the
|
|
303
|
+
* price is computed on the combined fare. A per-route field could not say
|
|
304
|
+
* either of those things.
|
|
305
|
+
*
|
|
306
|
+
* Disabled until there is a departure price to ask about, so it never fires
|
|
307
|
+
* on a half-built booking.
|
|
308
|
+
*/
|
|
309
|
+
export const useGetFlex = (
|
|
310
|
+
departurePriceId: (number | undefined),
|
|
311
|
+
returnPriceId: (number | undefined),
|
|
312
|
+
adults: number,
|
|
313
|
+
children: 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
|
|
319
|
+
): UseQueryResult<FlexOffer> => (
|
|
152
320
|
useQuery({
|
|
153
|
-
queryKey: ['
|
|
321
|
+
queryKey: ['flex-offer', { departurePriceId, returnPriceId, adults, children, babies, total }],
|
|
322
|
+
enabled: departurePriceId !== undefined,
|
|
154
323
|
queryFn: async () => (
|
|
155
324
|
await apiClient
|
|
156
|
-
.get('/api/
|
|
325
|
+
.get('/api/orders/flex', {
|
|
326
|
+
params: {
|
|
327
|
+
departure_price_id: departurePriceId,
|
|
328
|
+
return_price_id: returnPriceId,
|
|
329
|
+
adults,
|
|
330
|
+
children,
|
|
331
|
+
babies,
|
|
332
|
+
...(total !== undefined ? { total } : {})
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
.then(response => response.data)
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Watch a fare: email me if the price for this pair and date drops.
|
|
342
|
+
*
|
|
343
|
+
* Edited: Claude - Date: 2026-08-20
|
|
344
|
+
*
|
|
345
|
+
* Typed against AxiosError rather than Error, and that is the point of the
|
|
346
|
+
* signature: the caller has four different things to say depending on the
|
|
347
|
+
* status (404 nobody prices this pair, 422 bad address, 429 throttled,
|
|
348
|
+
* anything else unreachable), and with a bare `Error` the status would be
|
|
349
|
+
* an unchecked cast at the one place it matters.
|
|
350
|
+
*
|
|
351
|
+
* NO `token` FIELD, ever. obtapi validates it as `prohibited` on this
|
|
352
|
+
* endpoint - it identifies a mobile device on the push half of the same
|
|
353
|
+
* feature - so sending one turns a working request into a 422.
|
|
354
|
+
*
|
|
355
|
+
* `Content-Language` is not set here: apiClient's request interceptor
|
|
356
|
+
* already sends the live UI language on every call, and the server reads
|
|
357
|
+
* the alert's language off it. That is the honest answer to "which language
|
|
358
|
+
* should these emails be in" - the one they are reading the site in right
|
|
359
|
+
* now - and one nobody can get wrong in a form.
|
|
360
|
+
*/
|
|
361
|
+
export const usePostPriceAlert = (): UseMutationResult<PriceAlertData, AxiosError, PriceAlertRequest, unknown> => (
|
|
362
|
+
useMutation({
|
|
363
|
+
mutationKey: ['watch-price-alert'],
|
|
364
|
+
mutationFn: async (data: PriceAlertRequest) => (
|
|
365
|
+
await apiClient
|
|
366
|
+
.post('/api/alerts/price/email', data)
|
|
367
|
+
.then(response => (
|
|
368
|
+
response.data
|
|
369
|
+
))
|
|
370
|
+
),
|
|
371
|
+
|
|
372
|
+
// the two answers that are not "try again" - an unpriced pair and a
|
|
373
|
+
// rejected address - are both final, and retrying a 429 is the one
|
|
374
|
+
// thing guaranteed to keep it a 429
|
|
375
|
+
retry: false
|
|
376
|
+
})
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The travellers this account has saved, measured against THIS journey.
|
|
381
|
+
*
|
|
382
|
+
* Edited: Claude - Date: 2026-08-20
|
|
383
|
+
*
|
|
384
|
+
* The price ids are what make the answer journey-specific: obtapi puts a
|
|
385
|
+
* `missing` array on every row naming the fields these particular routes
|
|
386
|
+
* collect and this particular saved traveller has not got (see
|
|
387
|
+
* Account\PassengersController::browse). Without them the rows come back
|
|
388
|
+
* with no `missing` key at all, which is a different answer from an empty
|
|
389
|
+
* one - so they are always sent from here.
|
|
390
|
+
*
|
|
391
|
+
* `enabled` is the whole gate, and it is not an optimisation. The endpoint
|
|
392
|
+
* is `role:visitor`, so a guest or a member of staff gets a 404 - a correct
|
|
393
|
+
* refusal that confirms nothing, and one this UI must never turn into an
|
|
394
|
+
* error on somebody's checkout. Not asking is the only way to be sure of
|
|
395
|
+
* that; `retry: false` covers the case where the account changes underneath
|
|
396
|
+
* a query already in flight.
|
|
397
|
+
*/
|
|
398
|
+
export const useGetSavedPassengers = (
|
|
399
|
+
departurePriceId: (number | undefined),
|
|
400
|
+
returnPriceId: (number | undefined),
|
|
401
|
+
enabled: boolean
|
|
402
|
+
): UseQueryResult<SavedPassengersData> => (
|
|
403
|
+
useQuery({
|
|
404
|
+
queryKey: ['saved-passengers-checkout', { departurePriceId, returnPriceId }],
|
|
405
|
+
enabled: enabled && departurePriceId !== undefined,
|
|
406
|
+
retry: false,
|
|
407
|
+
queryFn: async () => (
|
|
408
|
+
await apiClient
|
|
409
|
+
.get('/api/account/passengers/browse', {
|
|
410
|
+
params: {
|
|
411
|
+
departure_price_id: departurePriceId,
|
|
412
|
+
return_price_id: returnPriceId
|
|
413
|
+
}
|
|
414
|
+
})
|
|
157
415
|
.then(response => (
|
|
158
416
|
response.data
|
|
159
417
|
))
|
|
160
418
|
)
|
|
161
419
|
})
|
|
162
|
-
);
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* One saved traveller, passport included.
|
|
424
|
+
*
|
|
425
|
+
* Edited: Claude - Date: 2026-08-20
|
|
426
|
+
*
|
|
427
|
+
* A mutation for a GET, deliberately. This is not data the screen renders -
|
|
428
|
+
* it is fetched at the moment somebody picks a name out of the chooser, and
|
|
429
|
+
* what comes back is written into a form rather than displayed. A query
|
|
430
|
+
* would need the chosen id in render state and an effect to notice it
|
|
431
|
+
* landing; `mutate(id, { onSuccess })` is the same fetch with the answer
|
|
432
|
+
* arriving where the decision was made.
|
|
433
|
+
*
|
|
434
|
+
* This is also the ONLY request in the app that returns a stored passport
|
|
435
|
+
* number, which is why it is one traveller at a time and only after a
|
|
436
|
+
* customer has said who is sitting in this seat.
|
|
437
|
+
*/
|
|
438
|
+
export const useGetSavedPassenger = (): UseMutationResult<SavedPassengerRecord, Error, number, unknown> => (
|
|
439
|
+
useMutation({
|
|
440
|
+
mutationKey: ['saved-passenger-record'],
|
|
441
|
+
mutationFn: async (id: number) => (
|
|
442
|
+
await apiClient
|
|
443
|
+
.get('/api/account/passengers/get', {
|
|
444
|
+
params: { id }
|
|
445
|
+
})
|
|
446
|
+
.then(response => (
|
|
447
|
+
response.data
|
|
448
|
+
))
|
|
449
|
+
)
|
|
450
|
+
})
|
|
451
|
+
);
|
package/styles.ts
CHANGED
|
@@ -21,4 +21,130 @@ export const Actions = styled.div`
|
|
|
21
21
|
justify-content: center;
|
|
22
22
|
gap: 20px;
|
|
23
23
|
margin-top: 25px;
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The search form kept on screen while browsing results.
|
|
28
|
+
*
|
|
29
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
30
|
+
*
|
|
31
|
+
* Changing a date or a city used to mean going Back to Search and losing
|
|
32
|
+
* your place. The form stays available above the results so an adjustment
|
|
33
|
+
* is one edit rather than a round trip.
|
|
34
|
+
*/
|
|
35
|
+
export const SearchAgain = styled.div<{ $background: string }>`
|
|
36
|
+
position: relative;
|
|
37
|
+
margin: -35px calc(50% - 50vw) 20px;
|
|
38
|
+
padding: 18px calc(50vw - 50%);
|
|
39
|
+
background-image: url('${ props => props.$background }');
|
|
40
|
+
background-size: cover;
|
|
41
|
+
background-repeat: no-repeat;
|
|
42
|
+
background-position: center;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Same scrim as the homepage hero, for the same reason: the artwork a
|
|
46
|
+
* brand supplies may be light or dark, and the controls on top of it must
|
|
47
|
+
* read either way.
|
|
48
|
+
*/
|
|
49
|
+
&::before {
|
|
50
|
+
content: '';
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
pointer-events: none;
|
|
54
|
+
background: rgba(0, 0, 0, .45);
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
export const SearchInner = styled.div`
|
|
59
|
+
position: relative;
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
export const SearchToggle = styled.button`
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: 8px;
|
|
66
|
+
padding: 6px 14px;
|
|
67
|
+
font-size: ${ props => props.theme.size.s };
|
|
68
|
+
font-weight: 700;
|
|
69
|
+
color: #FFF;
|
|
70
|
+
border: 1px solid rgba(255, 255, 255, .45);
|
|
71
|
+
border-radius: 100px;
|
|
72
|
+
transition: all 0.2s ease;
|
|
73
|
+
|
|
74
|
+
&:hover {
|
|
75
|
+
border-color: #FFF;
|
|
76
|
+
background: rgba(255, 255, 255, .12);
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
export const SearchSummary = styled.span`
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
text-overflow: ellipsis;
|
|
83
|
+
white-space: nowrap;
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
88
|
+
*
|
|
89
|
+
* Collapsed by default: the form is there for the minority who want to
|
|
90
|
+
* change something, and left open it pushed the results themselves below
|
|
91
|
+
* the fold on the page whose whole job is showing results.
|
|
92
|
+
*/
|
|
93
|
+
export const SearchPanel = styled.div<{ $open: boolean }>`
|
|
94
|
+
display: ${ props => props.$open ? 'block' : 'none' };
|
|
95
|
+
margin-top: 14px;
|
|
96
|
+
padding: 20px;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Edited: Claude - Date: 2026-08-29
|
|
100
|
+
*
|
|
101
|
+
* ABOVE THE STEPS, because backdrop-filter below creates a stacking
|
|
102
|
+
* context: everything inside this panel is confined to it, so the date
|
|
103
|
+
* picker's own z-index (100) could never beat the step markers sitting
|
|
104
|
+
* at z-index 1 in the page's context. Opening the collapsed search and
|
|
105
|
+
* clicking a date drew the calendar UNDERNEATH the 1-2-3-4 circles.
|
|
106
|
+
* Raising the panel itself lifts its whole subtree with it.
|
|
107
|
+
*/
|
|
108
|
+
position: relative;
|
|
109
|
+
z-index: 20;
|
|
110
|
+
background: ${ props => props.theme.header.transparent };
|
|
111
|
+
backdrop-filter: blur(25px);
|
|
112
|
+
-webkit-backdrop-filter: blur(25px);
|
|
113
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Results layout: filters beside the list, not above it.
|
|
118
|
+
*
|
|
119
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
120
|
+
*
|
|
121
|
+
* Below 1280px this is a plain column and the filters stay a collapsible
|
|
122
|
+
* panel. At and above it the filters take a fixed column and the results
|
|
123
|
+
* take the rest. See Found/Filters/styles.ts for why the breakpoint is
|
|
124
|
+
* 1280 rather than 1024.
|
|
125
|
+
*/
|
|
126
|
+
export const Results = styled.div`
|
|
127
|
+
display: flex;
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
|
|
130
|
+
@media (min-width: 1280px) {
|
|
131
|
+
flex-direction: row;
|
|
132
|
+
align-items: flex-start;
|
|
133
|
+
gap: 20px;
|
|
134
|
+
}
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
export const Sidebar = styled.aside`
|
|
138
|
+
min-width: 0;
|
|
139
|
+
|
|
140
|
+
@media (min-width: 1280px) {
|
|
141
|
+
flex: 0 0 290px;
|
|
142
|
+
}
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
export const Listing = styled.div`
|
|
146
|
+
// min-width:0 stops a long stop name inside a card from forcing this
|
|
147
|
+
// column wider than its share of the row
|
|
148
|
+
flex: 1;
|
|
149
|
+
min-width: 0;
|
|
24
150
|
`;
|
package/types.ts
CHANGED
|
@@ -2,6 +2,36 @@ export interface FoundDay {
|
|
|
2
2
|
day: string
|
|
3
3
|
id: string
|
|
4
4
|
routes: boolean
|
|
5
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
6
|
+
// Cheapest fare that day for the current passenger mix, null when the day
|
|
7
|
+
// has nothing. Optional so an older obtapi - and any API partner reading
|
|
8
|
+
// /routes/search/dates - keeps type-checking.
|
|
9
|
+
from_price?: number | null
|
|
10
|
+
from_price_display?: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
15
|
+
* Alternatives offered when a search finds nothing - see obtapi's
|
|
16
|
+
* Routes\Search\Nearby.
|
|
17
|
+
*/
|
|
18
|
+
export interface NearbyCity {
|
|
19
|
+
city: {
|
|
20
|
+
id: number
|
|
21
|
+
name: string
|
|
22
|
+
slug: string
|
|
23
|
+
}
|
|
24
|
+
/** kilometres from the city the passenger actually asked for */
|
|
25
|
+
distance: number
|
|
26
|
+
routes: number
|
|
27
|
+
from_price: number
|
|
28
|
+
from_price_display: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AlternativesData {
|
|
32
|
+
from: NearbyCity[]
|
|
33
|
+
to: NearbyCity[]
|
|
34
|
+
dates: FoundDay[]
|
|
5
35
|
}
|
|
6
36
|
|
|
7
37
|
export interface CouponForm {
|
|
@@ -12,6 +42,7 @@ export interface ActionData {
|
|
|
12
42
|
fake: boolean
|
|
13
43
|
reserve: boolean
|
|
14
44
|
funds: boolean
|
|
45
|
+
complimentary?: boolean
|
|
15
46
|
bkt: boolean
|
|
16
47
|
stripe: boolean
|
|
17
48
|
mollie: boolean
|
|
@@ -41,4 +72,56 @@ export interface AddonsSelection {
|
|
|
41
72
|
whatsapp: boolean
|
|
42
73
|
whatsappNumber: string
|
|
43
74
|
telegram: boolean
|
|
44
|
-
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
78
|
+
* The paid per-order CONFIRMATION by SMS - route alerts over SMS are free
|
|
79
|
+
* and need nothing here. Carries its own number like WhatsApp does, since
|
|
80
|
+
* somebody booking for a relative wants the text to reach the traveller.
|
|
81
|
+
*/
|
|
82
|
+
sms: boolean
|
|
83
|
+
smsNumber: string
|
|
84
|
+
|
|
85
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
86
|
+
// Flexible Ticket. A bare boolean, like Telegram - the window and the
|
|
87
|
+
// price are the server's to decide (Libraries\Orders\Flex), and a client
|
|
88
|
+
// that sent either would be ignored.
|
|
89
|
+
flex: boolean
|
|
90
|
+
|
|
91
|
+
// Edited: Claude - Date: 2026-08-26
|
|
92
|
+
// Seat selection is SOLD on this brand (settings addons.seats). What is
|
|
93
|
+
// actually bought is implicit in the seat fields themselves: the
|
|
94
|
+
// submission strips unpicked (zero) fields and sends addon_seats only
|
|
95
|
+
// when at least one hand-picked seat remains - see usePostOrder.
|
|
96
|
+
seats: boolean
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* "Watch this fare" - see Found/Watch/Watch.tsx.
|
|
100
|
+
*
|
|
101
|
+
* Edited: Claude - Date: 2026-08-20
|
|
102
|
+
*
|
|
103
|
+
* The form is one field because the pair and the date are the search that
|
|
104
|
+
* has already been run. `token` is NOT here and must never be: obtapi's
|
|
105
|
+
* Alerts\PriceController::email() validates it as `prohibited`, since that
|
|
106
|
+
* field identifies a mobile device on the push half of the same feature and
|
|
107
|
+
* an alert notifies exactly one way.
|
|
108
|
+
*/
|
|
109
|
+
export interface PriceAlertForm {
|
|
110
|
+
email: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PriceAlertRequest extends PriceAlertForm {
|
|
114
|
+
from: string
|
|
115
|
+
to: string
|
|
116
|
+
/** d/m/Y, the format the search form already holds a date in */
|
|
117
|
+
departure: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface PriceAlertData {
|
|
121
|
+
id: number
|
|
122
|
+
|
|
123
|
+
// the fare the alert was armed against, already formatted in the label's
|
|
124
|
+
// currency by the server - the customer is told the number being watched
|
|
125
|
+
// rather than being left to guess which of the prices on screen it was
|
|
126
|
+
baseline_price_display: string
|
|
127
|
+
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { TFunction } from 'i18next';
|
|
2
|
-
import { createDate } from '@autobusal/utilities';
|
|
3
|
-
import { Container, About, LinkBooking } from './styles';
|
|
4
|
-
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
5
|
-
import { FoundData } from '@autobusal/providers/types/routes';
|
|
6
|
-
import { useGetSettings } from '@autobusal/providers/services';
|
|
7
|
-
|
|
8
|
-
interface Props {
|
|
9
|
-
data?: FoundData[]
|
|
10
|
-
step1: RoutesSearchForm
|
|
11
|
-
t: TFunction<'public'>
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const Booking = ({ data, step1, t }: Props): (JSX.Element | null) => {
|
|
15
|
-
const { data: settings } = useGetSettings();
|
|
16
|
-
|
|
17
|
-
if (data === undefined || data?.length === 0) {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const destination = data[0].locations.to;
|
|
22
|
-
|
|
23
|
-
// stop if no booking city
|
|
24
|
-
if (destination.city.booking_id === undefined) {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// stop if no booking id
|
|
29
|
-
if (settings.preferences.bookingID === undefined) {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const from = createDate(step1.departure);
|
|
34
|
-
const to = getReturnDate(step1._return, from);
|
|
35
|
-
|
|
36
|
-
const location = `https://www.booking.com/searchresults.xu.html?city=${ destination.city.booking_id }&aid=&checkin_monthday=${ from.getDate() }&checkin_month=${ from.getMonth() + 1 }&checkin_year=${ from.getFullYear() }&checkout_monthday=${ to.getDate() }&checkout_month=${ to.getMonth() + 1 }&checkout_year=${ to.getFullYear() }&no_rooms=1&group_adults=2&room1=A%2CA`;
|
|
37
|
-
|
|
38
|
-
return (
|
|
39
|
-
<Container>
|
|
40
|
-
<About>
|
|
41
|
-
{ t('routes_order.step2.booking.title', { location: destination.city.name }) }
|
|
42
|
-
<img src="https://s-ec.bstatic.com/static/img/b26logo/booking_logo_retina/22615963add19ac6b6d715a97c8d477e8b95b7ea.png" alt="Booking.com" />
|
|
43
|
-
</About>
|
|
44
|
-
|
|
45
|
-
<LinkBooking to={ location } target="_blank" rel="noopener noreferrer">{ t('routes_order.step2.booking.search') }</LinkBooking>
|
|
46
|
-
</Container>
|
|
47
|
-
);
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const getReturnDate = (_return: (string | undefined), from: Date): Date => {
|
|
51
|
-
if (_return !== undefined) {
|
|
52
|
-
return createDate(_return);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const to = new Date(from);
|
|
56
|
-
to.setDate(to.getDate() + 2);
|
|
57
|
-
|
|
58
|
-
return to;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export default Booking;
|