@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +731 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +95 -5
  14. package/Found/Dates/styles.ts +173 -9
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +194 -0
  18. package/Found/Filters/styles.ts +188 -0
  19. package/Found/Found.tsx +207 -17
  20. package/Found/Group/Group.tsx +68 -0
  21. package/Found/Group/styles.ts +44 -0
  22. package/Found/Header/styles.ts +20 -4
  23. package/Found/Orders/Orders.tsx +89 -27
  24. package/Found/Orders/styles.ts +14 -0
  25. package/Found/Picks/Picks.tsx +75 -0
  26. package/Found/Picks/styles.ts +106 -0
  27. package/Found/Route/Route.tsx +211 -33
  28. package/Found/Route/styles.ts +491 -46
  29. package/Found/SameDay/SameDay.tsx +35 -0
  30. package/Found/SameDay/styles.ts +27 -0
  31. package/Found/Sort/Sort.tsx +40 -0
  32. package/Found/Sort/styles.ts +87 -0
  33. package/Found/Summary/Summary.tsx +49 -0
  34. package/Found/Summary/styles.ts +42 -0
  35. package/Found/Watch/Watch.tsx +187 -0
  36. package/Found/Watch/styles.ts +118 -0
  37. package/Found/refine.ts +652 -0
  38. package/Links/Links.tsx +91 -0
  39. package/Links/styles.ts +75 -0
  40. package/RoutesOrder.tsx +239 -31
  41. package/Schedule/Schedule.tsx +123 -0
  42. package/Schedule/styles.ts +71 -0
  43. package/Sections/Schema.tsx +186 -0
  44. package/Sections/Sections.tsx +160 -0
  45. package/Sections/styles.ts +44 -0
  46. package/Step1/prepare.ts +21 -2
  47. package/Step2.tsx +34 -1
  48. package/Step4/Passenger/Passenger.tsx +253 -24
  49. package/Step4/Passenger/styles.ts +21 -1
  50. package/Step4/Passengers/Display.tsx +10 -2
  51. package/Step4/Passengers/Passengers.tsx +53 -4
  52. package/Step4/Saved/Choose.tsx +99 -0
  53. package/Step4/Saved/Remember.tsx +58 -0
  54. package/Step4/Saved/styles.ts +87 -0
  55. package/Step4/checkAge.ts +15 -4
  56. package/Step5/Addons/Addons.tsx +146 -4
  57. package/Step5/Addons/styles.ts +94 -0
  58. package/Step5/Billing/Billing.tsx +138 -70
  59. package/Step5/Coupons/Coupons.tsx +28 -18
  60. package/Step5/Coupons/styles.ts +41 -8
  61. package/Step5/Payments/Payments.tsx +36 -5
  62. package/Step5/Payments/styles.ts +16 -7
  63. package/Step5/Summary/About/About.tsx +99 -27
  64. package/Step5/Summary/About/styles.ts +118 -1
  65. package/Step5/Summary/styles.ts +12 -5
  66. package/Step5/utilities.ts +67 -12
  67. package/Steps/Steps.tsx +3 -1
  68. package/commerce.ts +24 -0
  69. package/package.json +1 -1
  70. package/services.ts +302 -13
  71. package/styles.ts +126 -0
  72. package/types.ts +84 -1
  73. package/Found/Booking/Booking.tsx +0 -61
  74. package/Found/Booking/styles.ts +0 -50
  75. package/Step4/Step4.tsx +0 -114
  76. 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 { apiClient } from '@autobusal/providers';
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
- queryKey: ['step2-dates', { number }],
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, BillingData, unknown> => (
142
+ ): UseMutationResult<OrderedData, Error, OrderSubmission, unknown> => (
80
143
  useMutation({
81
144
  mutationKey: ['save-order'],
82
- mutationFn: async (data: BillingData) => {
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
- export const useGetPayments = (): UseQueryResult<ActionData> => (
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: ['allowed-funds'],
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/funds/allowed')
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;