@autobusal/routes-order 1.32.0 → 1.32.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.32.1
4
+
5
+ ### Added
6
+
7
+ - **A saved-passenger chooser and a "remember this passenger" checkbox**, per passenger rather than per order, because "remember me but not the person I am buying for" is the ordinary case and one control per booking cannot say it. The list is fetched once for the whole checkout, not once per traveller.
8
+
9
+ Picking applies the whole person, not the filled parts: a passport left from a previous choice is cleared, because the customer has said who is sitting in that seat. Only fields the route actually collects are written - react-hook-form submits values set for names it never registered, so writing the rest would post a WhatsApp number to a route that never asked for one.
10
+
11
+ A journey that needs a field the saved profile lacks says so in the form's own words. `missing` absent means *not measured* and is not treated as "nothing missing".
12
+
3
13
  ## 1.32.0
4
14
 
5
15
  ### Added
@@ -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, useGetFlex, useGetPayments } from '../services';
27
+ import { usePostOrder, useGetFlex, useGetPayments, useGetSavedPassengers } from '../services';
28
28
  import { addon as commerceAddon, beginCheckout, stash } from '@autobusal/providers/Setup/commerce';
29
29
  import { item as commerceItem } from '../commerce';
30
30
 
@@ -125,6 +125,38 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
125
125
  step1.babies ?? 0
126
126
  );
127
127
 
128
+ /**
129
+ * "Book somebody you have booked before" - saved passengers.
130
+ *
131
+ * Edited: Claude - Date: 2026-08-20
132
+ *
133
+ * Fetched ONCE here and drilled down rather than asked for inside each
134
+ * passenger block: a family of five would otherwise be five requests for
135
+ * one list, and the list is the same list.
136
+ *
137
+ * ONLY FOR A SIGNED-IN CUSTOMER. The endpoint is `role:visitor`, so a
138
+ * guest and a member of staff both get a 404 - a deliberate refusal that
139
+ * confirms nothing (see obtapi's Account\PassengersController). Deciding
140
+ * that here, from a fact the app already knows, means the request is never
141
+ * made for them and there is no failed query on a checkout screen to
142
+ * accidentally surface.
143
+ *
144
+ * The price ids go with it so every row comes back saying what THIS
145
+ * journey would still have to ask for - see the chooser.
146
+ */
147
+ const isCustomer = UserData?.type === 'visitor';
148
+
149
+ const { data: SavedData } = useGetSavedPassengers(
150
+ step2.price.id as number,
151
+ step3?.price.id as (number | undefined),
152
+ isCustomer
153
+ );
154
+
155
+ // undefined until the list has landed, which is also what a guest gets and
156
+ // is exactly the right answer for both: nothing about the feature renders
157
+ // until there is an account behind it with an answer
158
+ const saved = isCustomer ? (SavedData?.data ?? []) : undefined;
159
+
128
160
  const addonItems = [
129
161
  addonWhatsapp && SettingsData?.addons?.whatsapp.available ? {
130
162
  label: t('routes_order.step5.addons.whatsapp.label'),
@@ -280,6 +312,8 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
280
312
  fields={ fields }
281
313
  busFrom={ busFrom }
282
314
  busTo={ busTo }
315
+ saved={ saved }
316
+ savedMax={ SavedData?.max }
283
317
  t={ t }
284
318
  errors={ passenger.formState.errors }
285
319
  refs={ passenger.register }
@@ -1,10 +1,13 @@
1
- import { useId } from 'react';
1
+ import { useId, useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
3
  import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form';
4
4
  import { Calendar, Gender, ChooseSeat, Required } from '@autobusal/common';
5
5
  import { Validate, Display } from '@autobusal/utilities';
6
+ import Choose from '../Saved/Choose';
7
+ import Remember from '../Saved/Remember';
8
+ import { useGetSavedPassenger } from '../../services';
6
9
  import { Container, Title, Notice } from './styles';
7
- import { PersonData } from '@autobusal/providers/types/persons';
10
+ import { PersonData, SavedPassenger, SavedPassengerRecord } from '@autobusal/providers/types/persons';
8
11
  import { OccupiedData } from '@autobusal/providers/types/buses';
9
12
 
10
13
  interface Props {
@@ -17,6 +20,17 @@ interface Props {
17
20
  busTo: OccupiedData
18
21
  pickedFrom: number[]
19
22
  pickedTo: number[]
23
+ /**
24
+ * Edited: Claude - Date: 2026-08-20
25
+ * The travellers this account has saved, or undefined when there is no
26
+ * account to have saved any - a guest, or a member of staff. UNDEFINED IS
27
+ * NOT AN EMPTY LIST: an empty list is a customer who has saved nobody yet
28
+ * and may tick "remember"; undefined is somebody the whole feature does
29
+ * not apply to, and nothing about it renders.
30
+ */
31
+ saved?: SavedPassenger[]
32
+ /** how many travellers one account may keep (obtapi's own cap) */
33
+ savedMax?: number
20
34
  t: TFunction<'public'>
21
35
  errors: FieldErrors<FieldValues>
22
36
  refs: UseFormRegister<PersonData>
@@ -30,7 +44,7 @@ interface Props {
30
44
  // telegram render ONLY when the route requires them - and are then
31
45
  // required. Name and date of birth stay mandatory always. `fields` is the
32
46
  // union of both legs on a return trip (see Step4).
33
- const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, pickedFrom, pickedTo, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element => {
47
+ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element => {
34
48
  const name = `${ type }${ number }`;
35
49
 
36
50
  const names = {
@@ -43,7 +57,12 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
43
57
  whatsapp: `${ name }_wa`,
44
58
  telegram: `${ name }_tg`,
45
59
  seatDeparture: `${ name }_sd`,
46
- seatReturn: `${ name }_sr`
60
+ seatReturn: `${ name }_sr`,
61
+
62
+ // Edited: Claude - Date: 2026-08-20
63
+ // "Remember this passenger" - obtapi reads `{type}{i}_save` alongside
64
+ // the `_fn` / `_pass` fields this form already posts.
65
+ save: `${ name }_save`
47
66
  };
48
67
 
49
68
  const has = (field: string): boolean => fields.includes(field);
@@ -62,20 +81,161 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
62
81
 
63
82
  const id = (field: string): string => `${ uid }${ field }`;
64
83
 
84
+ /**
85
+ * "Book somebody you have booked before" - the saved-passenger chooser.
86
+ *
87
+ * Edited: Claude - Date: 2026-08-20
88
+ *
89
+ * `picked` is the row currently filling these fields; `applied` counts how
90
+ * many times a choice has been applied, and exists only to key the two
91
+ * controls below that cannot be told a new value.
92
+ *
93
+ * WHY A KEY AT ALL. Every field here is uncontrolled - a defaultValue plus
94
+ * a react-hook-form registration - so nothing re-reads its default on a
95
+ * re-render, which is exactly right while somebody is typing and exactly
96
+ * wrong the moment they pick a different traveller. Remounting on a new
97
+ * key is what makes the visible form agree with the form's data, and it is
98
+ * done per field rather than by remounting the passenger, because the seat
99
+ * pickers hold state of their own that a chosen name has nothing to do
100
+ * with.
101
+ */
102
+ const [ picked, setPicked ] = useState<number>(0);
103
+ const [ applied, setApplied ] = useState<number>(0);
104
+ const [ record, setRecord ] = useState<SavedPassengerRecord | undefined>(undefined);
105
+
106
+ const { mutate: Fetch, isPending: isFetching } = useGetSavedPassenger();
107
+
108
+ /**
109
+ * What a field starts as: the chosen traveller's value once somebody has
110
+ * chosen one, otherwise whatever was typed before leaving this screen.
111
+ */
112
+ const start = (field: string, chosen?: (string | number | null)): string => {
113
+ if (record !== undefined) {
114
+ return chosen === null || chosen === undefined ? '' : String(chosen);
115
+ }
116
+
117
+ return values !== undefined ? String(values[field] ?? '') : '';
118
+ };
119
+
120
+ /**
121
+ * Apply a chosen traveller to this passenger's fields.
122
+ *
123
+ * THE WHOLE PERSON, NOT THE PARTS THAT HAPPEN TO BE FILLED. The customer
124
+ * has said who is sitting in this seat, so a passport left over from the
125
+ * previous choice is not theirs and is cleared rather than kept - the
126
+ * chooser's notice says what is now missing, which is a far better answer
127
+ * than a form quietly carrying one traveller's document under another
128
+ * traveller's name.
129
+ *
130
+ * The values are written BEFORE the remount so react-hook-form and the
131
+ * freshly mounted inputs start from the same answer, whichever of the two
132
+ * the library chooses to trust when a registered field reappears.
133
+ */
134
+ const apply = (chosen: SavedPassengerRecord): void => {
135
+ onUpdate(names.firstName, chosen.first_name);
136
+ onUpdate(names.lastName, chosen.last_name);
137
+ onUpdate(names.dob, chosen.dob);
138
+
139
+ // ONLY THE FIELDS THIS ROUTE COLLECTS. react-hook-form keeps a value set
140
+ // for a name it never registered and submits it with the rest, so writing
141
+ // all five here would post a WhatsApp number on a route that never asked
142
+ // for one - and obtapi's capture-at-checkout merges whatever it is sent.
143
+ // The form should post exactly what it shows.
144
+ if (has('sex')) {
145
+ // 0 is a perfectly good answer here, so this tests for null rather than
146
+ // for falsiness. A traveller with no stored sex leaves the control at
147
+ // its own default and is reported by `missing` instead.
148
+ onUpdate(names.sex, chosen.sex === null ? 0 : chosen.sex);
149
+ }
150
+
151
+ if (has('passport')) {
152
+ onUpdate(names.passport, chosen.passport ?? '');
153
+ }
154
+
155
+ if (has('phone')) {
156
+ onUpdate(names.phone, chosen.phone ?? '');
157
+ }
158
+
159
+ if (has('whatsapp')) {
160
+ onUpdate(names.whatsapp, chosen.whatsapp ?? '');
161
+ }
162
+
163
+ if (has('telegram')) {
164
+ onUpdate(names.telegram, chosen.telegram ?? '');
165
+ }
166
+
167
+ setRecord(chosen);
168
+ setApplied(applied + 1);
169
+ };
170
+
171
+ /**
172
+ * A pick is a fetch, because the list does not carry passport numbers -
173
+ * obtapi masks them there and returns the real one for one traveller at a
174
+ * time, at this exact moment. Choosing the blank option clears back to an
175
+ * empty form rather than leaving the last traveller's details behind under
176
+ * a chooser that no longer names them.
177
+ */
178
+ const onPick = (id: number): void => {
179
+ setPicked(id);
180
+
181
+ if (id === 0) {
182
+ onUpdate(names.firstName, '');
183
+ onUpdate(names.lastName, '');
184
+ onUpdate(names.dob, '');
185
+
186
+ ['passport', 'phone', 'whatsapp', 'telegram'].forEach(field => {
187
+ if (has(field)) {
188
+ onUpdate(names[field as keyof typeof names], '');
189
+ }
190
+ });
191
+
192
+ setRecord(undefined);
193
+ setApplied(applied + 1);
194
+
195
+ return;
196
+ }
197
+
198
+ Fetch(id, {
199
+ onSuccess: (data) => apply(data)
200
+ });
201
+ };
202
+
203
+ // An account at its cap can still MERGE into a traveller it already has -
204
+ // obtapi only refuses new people - so the box is only closed off when
205
+ // nobody has been chosen to merge into.
206
+ const full = saved !== undefined && savedMax !== undefined && savedMax > 0 && saved.length >= savedMax && picked === 0;
207
+
65
208
  return (
66
209
  <Container className="box">
67
210
  <Title>
68
211
  { `${ t('routes_order.step4.ticket', { no: number }) } (${ t(`data.persons.${ type }.singular`) })` }
69
212
  </Title>
70
213
 
214
+ { /* Edited: Claude - Date: 2026-08-20
215
+ Only for a signed-in customer who has actually saved somebody -
216
+ `saved` is undefined for a guest or for staff, and an empty list
217
+ is an account with nobody on it yet. Either way nothing renders,
218
+ so the form a first-time buyer sees is the form they saw before. */ }
219
+ { (saved !== undefined && saved.length > 0) && (
220
+ <Choose
221
+ passengers={ saved }
222
+ picked={ picked }
223
+ id={ id('saved_choose') }
224
+ pending={ isFetching }
225
+ t={ t }
226
+ onPick={ onPick }
227
+ />
228
+ ) }
229
+
71
230
  <div className="column">
72
231
  <div className="row">
73
232
  <Required htmlFor={ id(names.firstName) }>{ t('routes_order.step4.first_name') }</Required>
74
233
 
75
234
  <input
76
235
  type="text"
236
+ key={ `${ names.firstName }-${ applied }` }
77
237
  id={ id(names.firstName) }
78
- defaultValue={ values !== undefined ? values[names.firstName] : '' }
238
+ defaultValue={ start(names.firstName, record?.first_name) }
79
239
  { ...refs(names.firstName, Validate('required|min_length:2|max_length:100', t)) }
80
240
  />
81
241
 
@@ -87,8 +247,9 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
87
247
 
88
248
  <input
89
249
  type="text"
250
+ key={ `${ names.lastName }-${ applied }` }
90
251
  id={ id(names.lastName) }
91
- defaultValue={ values !== undefined ? values[names.lastName] : '' }
252
+ defaultValue={ start(names.lastName, record?.last_name) }
92
253
  { ...refs(names.lastName, Validate('required|min_length:2|max_length:100', t)) }
93
254
  />
94
255
 
@@ -101,10 +262,11 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
101
262
  <Required htmlFor={ id(names.dob) }>{ t('routes_order.step4.dob') }</Required>
102
263
 
103
264
  <Calendar
265
+ key={ `${ names.dob }-${ applied }` }
104
266
  type="dob"
105
267
  name={ names.dob }
106
268
  id={ id(names.dob) }
107
- defaultValue={ values !== undefined ? String(values[names.dob]) : undefined }
269
+ defaultValue={ record !== undefined ? record.dob : (values !== undefined ? String(values[names.dob]) : undefined) }
108
270
  t={ t }
109
271
  validation="required"
110
272
  refs={ refs }
@@ -119,8 +281,9 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
119
281
  { t('routes_order.step4.gender') }
120
282
 
121
283
  <Gender
284
+ key={ `${ names.sex }-${ applied }` }
122
285
  name={ names.sex }
123
- defaultValue={ values !== undefined ? Number(values[names.sex]) : undefined }
286
+ defaultValue={ record !== undefined ? (record.sex ?? undefined) : (values !== undefined ? Number(values[names.sex]) : undefined) }
124
287
  t={ t }
125
288
  refs={ refs }
126
289
  />
@@ -138,8 +301,9 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
138
301
 
139
302
  <input
140
303
  type="text"
304
+ key={ `${ names.passport }-${ applied }` }
141
305
  id={ id(names.passport) }
142
- defaultValue={ values !== undefined ? values[names.passport] : '' }
306
+ defaultValue={ start(names.passport, record?.passport) }
143
307
  { ...refs(names.passport, Validate('required|min_length:2|max_length:50', t)) }
144
308
  />
145
309
 
@@ -153,8 +317,9 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
153
317
 
154
318
  <input
155
319
  type="text"
320
+ key={ `${ names.phone }-${ applied }` }
156
321
  id={ id(names.phone) }
157
- defaultValue={ values !== undefined ? values[names.phone] : '' }
322
+ defaultValue={ start(names.phone, record?.phone) }
158
323
  { ...refs(names.phone, Validate('required|min_length:5|max_length:50', t)) }
159
324
  />
160
325
 
@@ -180,8 +345,9 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
180
345
 
181
346
  <input
182
347
  type="text"
348
+ key={ `${ names.whatsapp }-${ applied }` }
183
349
  id={ id(names.whatsapp) }
184
- defaultValue={ values !== undefined ? values[names.whatsapp] : '' }
350
+ defaultValue={ start(names.whatsapp, record?.whatsapp) }
185
351
  { ...refs(names.whatsapp, Validate('required|min_length:5|max_length:50', t)) }
186
352
  />
187
353
 
@@ -195,8 +361,9 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
195
361
 
196
362
  <input
197
363
  type="text"
364
+ key={ `${ names.telegram }-${ applied }` }
198
365
  id={ id(names.telegram) }
199
- defaultValue={ values !== undefined ? values[names.telegram] : '' }
366
+ defaultValue={ start(names.telegram, record?.telegram) }
200
367
  { ...refs(names.telegram, Validate('required|min_length:2|max_length:100', t)) }
201
368
  />
202
369
 
@@ -247,6 +414,23 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
247
414
  ) }
248
415
  </div>
249
416
  ) }
417
+
418
+ { /* Edited: Claude - Date: 2026-08-20
419
+ At the foot of the passenger it applies to, so the question and
420
+ the passport number it would store are read in one glance. Only
421
+ for a signed-in customer: a guest has no account to save into, and
422
+ obtapi's Libraries\Passengers\Capture skips staff accounts for a
423
+ different reason - selling to a walk-in traveller must not
424
+ accumulate that person's documents under the agency. */ }
425
+ { saved !== undefined && (
426
+ <Remember
427
+ name={ names.save }
428
+ id={ id(names.save) }
429
+ full={ full }
430
+ t={ t }
431
+ refs={ refs }
432
+ />
433
+ ) }
250
434
  </Container>
251
435
  );
252
436
  };
@@ -1,7 +1,7 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form';
3
3
  import Passenger from '../Passenger/Passenger';
4
- import { PersonData } from '@autobusal/providers/types/persons';
4
+ import { PersonData, SavedPassenger } from '@autobusal/providers/types/persons';
5
5
  import { OccupiedData } from '@autobusal/providers/types/buses';
6
6
 
7
7
  interface Props {
@@ -14,6 +14,8 @@ interface Props {
14
14
  busTo: OccupiedData
15
15
  pickedFrom: number[]
16
16
  pickedTo: number[]
17
+ saved?: SavedPassenger[]
18
+ savedMax?: number
17
19
  t: TFunction<'public'>
18
20
  errors: FieldErrors<FieldValues>
19
21
  refs: UseFormRegister<PersonData>
@@ -21,7 +23,7 @@ interface Props {
21
23
  onUpdate: (name: string, value: (string | number)) => void
22
24
  }
23
25
 
24
- const Display = ({ type, amount, fields, withReturn, values, busFrom, busTo, pickedFrom, pickedTo, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element[] => {
26
+ const Display = ({ type, amount, fields, withReturn, values, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element[] => {
25
27
  const passengers: JSX.Element[] = [];
26
28
 
27
29
  for (let i = 1; i <= amount; i++) {
@@ -37,6 +39,8 @@ const Display = ({ type, amount, fields, withReturn, values, busFrom, busTo, pic
37
39
  busTo={ busTo }
38
40
  pickedFrom={ pickedFrom }
39
41
  pickedTo={ pickedTo }
42
+ saved={ saved }
43
+ savedMax={ savedMax }
40
44
  t={ t }
41
45
  errors={ errors }
42
46
  refs={ refs }
@@ -4,7 +4,7 @@ import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form';
4
4
  import Display from './Display';
5
5
  import { Container } from './styles';
6
6
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
7
- import { PersonData } from '@autobusal/providers/types/persons';
7
+ import { PersonData, SavedPassenger } from '@autobusal/providers/types/persons';
8
8
  import { OccupiedData } from '@autobusal/providers/types/buses';
9
9
 
10
10
  interface Props {
@@ -13,13 +13,21 @@ interface Props {
13
13
  fields: string[]
14
14
  busFrom: OccupiedData
15
15
  busTo: OccupiedData
16
+ /**
17
+ * Edited: Claude - Date: 2026-08-20
18
+ * This account's saved travellers, drilled to every passenger block so the
19
+ * list is fetched once for the checkout rather than once per seat.
20
+ * Undefined for a guest or for staff - see Passenger.tsx.
21
+ */
22
+ saved?: SavedPassenger[]
23
+ savedMax?: number
16
24
  t: TFunction<'public'>
17
25
  errors: FieldErrors<FieldValues>
18
26
  refs: UseFormRegister<PersonData>
19
27
  onUpdate: (name: string, value: (string | number)) => void
20
28
  }
21
29
 
22
- const Passengers = ({ values, step1, fields, busFrom, busTo, t, errors, refs, onUpdate }: Props): JSX.Element => {
30
+ const Passengers = ({ values, step1, fields, busFrom, busTo, saved, savedMax, t, errors, refs, onUpdate }: Props): JSX.Element => {
23
31
  const [ pickedFrom, setPickedFrom ] = useState<number[]>([]);
24
32
  const [ pickedTo, setPickedTo ] = useState<number[]>([]);
25
33
 
@@ -53,6 +61,8 @@ const Passengers = ({ values, step1, fields, busFrom, busTo, t, errors, refs, on
53
61
  busTo={ busTo }
54
62
  pickedFrom={ pickedFrom }
55
63
  pickedTo={ pickedTo }
64
+ saved={ saved }
65
+ savedMax={ savedMax }
56
66
  t={ t }
57
67
  errors={ errors }
58
68
  refs={ refs }
@@ -70,6 +80,8 @@ const Passengers = ({ values, step1, fields, busFrom, busTo, t, errors, refs, on
70
80
  busTo={ busTo }
71
81
  pickedFrom={ pickedFrom }
72
82
  pickedTo={ pickedTo }
83
+ saved={ saved }
84
+ savedMax={ savedMax }
73
85
  t={ t }
74
86
  errors={ errors }
75
87
  refs={ refs }
@@ -87,6 +99,8 @@ const Passengers = ({ values, step1, fields, busFrom, busTo, t, errors, refs, on
87
99
  busTo={ busTo }
88
100
  pickedFrom={ pickedFrom }
89
101
  pickedTo={ pickedTo }
102
+ saved={ saved }
103
+ savedMax={ savedMax }
90
104
  t={ t }
91
105
  errors={ errors }
92
106
  refs={ refs }
@@ -0,0 +1,99 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Container, Label, Missing, Hint } from './styles';
3
+ import { SavedPassenger } from '@autobusal/providers/types/persons';
4
+
5
+ interface Props {
6
+ /** this account's saved travellers, already measured against this journey */
7
+ passengers: SavedPassenger[]
8
+ /** the row currently filling the fields below, 0 for "typing from scratch" */
9
+ picked: number
10
+ /** the id to give the select, so the visible label actually points at it */
11
+ id: string
12
+ pending: boolean
13
+ t: TFunction<'common'>
14
+ onPick: (id: number) => void
15
+ }
16
+
17
+ /**
18
+ * The option keys obtapi reports in `missing`, in the order the form asks
19
+ * for them, paired with the label the form already uses for each. One list,
20
+ * so the notice cannot name a field by a word that appears nowhere on the
21
+ * screen it is pointing at.
22
+ */
23
+ const LABELS: Record<string, string> = {
24
+ sex: 'routes_order.step4.gender',
25
+ passport: 'routes_order.step4.passport',
26
+ phone: 'routes_order.step4.phone',
27
+ whatsapp: 'routes_order.step4.whatsapp',
28
+ telegram: 'routes_order.step4.telegram'
29
+ };
30
+
31
+ /**
32
+ * "Book somebody you have booked before."
33
+ *
34
+ * Edited: Claude - Date: 2026-08-20
35
+ *
36
+ * WHAT IT IS FOR. The same families ride the same corridors several times a
37
+ * year, and every trip has been another round of typing the same passport
38
+ * number. This turns that into one choice.
39
+ *
40
+ * WHO SEES IT. Only a signed-in customer with at least one saved traveller.
41
+ * The list is not fetched at all for a guest or for staff (see
42
+ * useGetSavedPassengers), so this component is simply never rendered for
43
+ * them - the endpoint's 404 for those accounts never reaches a screen. An
44
+ * account that has saved nobody yet also sees nothing: an empty chooser
45
+ * above an empty form is one more thing to read and nothing to use.
46
+ *
47
+ * A NAMED PERSON IS NOT A COMPLETE ONE, and that is what `missing` is for.
48
+ * Routes collect different fields, so somebody saved on a route that only
49
+ * wanted a name has no passport and must still be offerable on a route that
50
+ * wants one. Rather than quietly filling four of five fields and letting
51
+ * validation explain the fifth after they press pay, the gap is named here,
52
+ * in the fields' own words, at the moment they pick.
53
+ *
54
+ * `missing` may be ABSENT rather than empty - obtapi omits it when no
55
+ * journey was named - so it is tested as an array. An undefined `missing`
56
+ * means "not measured", never "nothing needed", and this says nothing at
57
+ * all in that case rather than promising a completeness it has not checked.
58
+ */
59
+ const Choose = ({ passengers, picked, id, pending, t, onPick }: Props): JSX.Element => {
60
+ const chosen = passengers.find(item => item.id === picked);
61
+
62
+ const missing = Array.isArray(chosen?.missing) ? chosen.missing : [];
63
+
64
+ const named = missing
65
+ .filter(field => LABELS[field] !== undefined)
66
+ .map(field => t(LABELS[field], { ns: 'common' }));
67
+
68
+ return (
69
+ <Container>
70
+ <Label htmlFor={ id }>{ t('routes_order.step4.saved.choose', { ns: 'common' }) }</Label>
71
+
72
+ <select
73
+ id={ id }
74
+ value={ picked }
75
+ disabled={ pending }
76
+ onChange={ (event) => onPick(Number(event.target.value)) }
77
+ >
78
+ <option value={ 0 }>{ t('routes_order.step4.saved.none', { ns: 'common' }) }</option>
79
+
80
+ { passengers.map(item => (
81
+ <option key={ item.id } value={ item.id }>
82
+ { /* The date of birth is what tells two relatives with the same
83
+ name apart, and it is the one thing on a saved row that is
84
+ never blank. The passport tail is NOT shown here: the list
85
+ is a chooser on a shared screen, and it is enough that the
86
+ person can be named. */ }
87
+ { `${ item.first_name } ${ item.last_name } (${ item.dob })` }
88
+ </option>
89
+ )) }
90
+ </select>
91
+
92
+ { named.length > 0
93
+ ? <Missing>{ t('routes_order.step4.saved.missing', { fields: named.join(', '), ns: 'common' }) }</Missing>
94
+ : <Hint>{ t('routes_order.step4.saved.hint', { ns: 'common' }) }</Hint> }
95
+ </Container>
96
+ );
97
+ };
98
+
99
+ export default Choose;
@@ -0,0 +1,58 @@
1
+ import { TFunction } from 'i18next';
2
+ import { UseFormRegister } from 'react-hook-form';
3
+ import { Remember as Container } from './styles';
4
+ import { PersonData } from '@autobusal/providers/types/persons';
5
+
6
+ interface Props {
7
+ /** the checkout field this posts as - adult1_save, child2_save, ... */
8
+ name: string
9
+ id: string
10
+ /** whether this account is already at its limit, and cannot store another */
11
+ full: boolean
12
+ t: TFunction<'common'>
13
+ refs: UseFormRegister<PersonData>
14
+ }
15
+
16
+ /**
17
+ * "Remember this passenger for next time."
18
+ *
19
+ * Edited: Claude - Date: 2026-08-20
20
+ *
21
+ * OFF BY DEFAULT, and there is no version of this that is not. What the box
22
+ * stores is a passport number and a date of birth, frequently belonging to
23
+ * somebody who is not the buyer - a family books together, and grandmother
24
+ * did not agree to anything. Nobody is opted into keeping an identity
25
+ * document on file because they did not notice a tick.
26
+ *
27
+ * PER PASSENGER, for the same reason. "Remember me but not the person I am
28
+ * buying a ticket for" is an ordinary thing to want, and one checkbox for
29
+ * the whole order cannot say it. That is why this renders inside the
30
+ * passenger it belongs to rather than beside the paid add-ons.
31
+ *
32
+ * NOT REGISTERED WITH A `value`. react-hook-form hands back the value
33
+ * attribute for a ticked checkbox and `false` for an unticked one; with no
34
+ * value attribute it is a plain true/false, which is exactly the
35
+ * `nullable|boolean` obtapi validates this field as (Orders\Make\Rules) and
36
+ * exactly the set Libraries\Passengers\Capture treats as a yes.
37
+ *
38
+ * Rendered only for a signed-in customer - the caller decides that, from the
39
+ * same fact that decides whether the chooser above appears.
40
+ */
41
+ const Remember = ({ name, id, full, t, refs }: Props): JSX.Element => (
42
+ <Container>
43
+ <label htmlFor={ id }>
44
+ <input
45
+ type="checkbox"
46
+ id={ id }
47
+ disabled={ full }
48
+ { ...refs(name) }
49
+ />
50
+
51
+ { full
52
+ ? t('routes_order.step4.saved.full', { ns: 'common' })
53
+ : t('routes_order.step4.saved.remember', { ns: 'common' }) }
54
+ </label>
55
+ </Container>
56
+ );
57
+
58
+ export default Remember;
@@ -0,0 +1,87 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * "Book this person again" - the saved-passenger chooser.
5
+ *
6
+ * Edited: Claude - Date: 2026-08-20
7
+ *
8
+ * UNOBTRUSIVE ON PURPOSE. It sits above the fields it fills, and for a
9
+ * first-time buyer - which is most of them - it is not rendered at all. It
10
+ * is a shortcut past a form, not a step in one, so it is quieter than the
11
+ * form: no box of its own, no heading, and it never takes the visual weight
12
+ * of a required field away from the fields that are actually required.
13
+ */
14
+ export const Container = styled.div`
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 4px;
18
+ padding: 10px 12px;
19
+ margin-bottom: 16px;
20
+ border: 1px dashed ${ props => props.theme.background.neutral };
21
+ border-radius: 6px;
22
+
23
+ select {
24
+ max-width: 320px;
25
+ }
26
+ `;
27
+
28
+ export const Label = styled.label`
29
+ font-size: ${ props => props.theme.size.xs };
30
+ font-weight: 600;
31
+ `;
32
+
33
+ /**
34
+ * What this journey still needs from a traveller we already know.
35
+ *
36
+ * Amber rather than red: nothing has gone wrong and nothing has been
37
+ * refused - the person was saved on a route that never asked for a passport
38
+ * and this one does. It is an instruction, not an error, so it does not
39
+ * borrow the styling of the validation messages sitting a few pixels below
40
+ * it.
41
+ */
42
+ export const Missing = styled.small`
43
+ display: block;
44
+ margin-top: 2px;
45
+ font-size: ${ props => props.theme.size.xxs };
46
+ font-weight: 400;
47
+ line-height: 1.4;
48
+ color: ${ props => props.theme.font.warning };
49
+ `;
50
+
51
+ export const Hint = styled.small`
52
+ display: block;
53
+ font-size: ${ props => props.theme.size.xxs };
54
+ font-weight: 400;
55
+ line-height: 1.4;
56
+ color: ${ props => props.theme.font.faded };
57
+ `;
58
+
59
+ /**
60
+ * "Remember this passenger for next time".
61
+ *
62
+ * Edited: Claude - Date: 2026-08-20
63
+ *
64
+ * At the FOOT of the passenger it applies to rather than beside the paid
65
+ * add-ons, because it is a question about this person and not about the
66
+ * order - and because a checkbox that stores somebody's passport number
67
+ * should be read in the same glance as the passport number it would store.
68
+ */
69
+ export const Remember = styled.div`
70
+ margin-top: 12px;
71
+ padding-top: 12px;
72
+ border-top: 1px solid ${ props => props.theme.background.neutral };
73
+
74
+ label {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 8px;
78
+ font-size: ${ props => props.theme.size.xs };
79
+ font-weight: 400;
80
+ cursor: pointer;
81
+ }
82
+
83
+ input {
84
+ width: auto;
85
+ margin: 0;
86
+ }
87
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.32.0",
3
+ "version": "1.32.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/services.ts CHANGED
@@ -4,7 +4,7 @@ import { apiClient } from '@autobusal/providers';
4
4
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
5
5
  import { FoundData, FlexOffer } from '@autobusal/providers/types/routes';
6
6
  import { CouponData } from '@autobusal/providers/types/orders';
7
- import { BillingData, PersonData } from '@autobusal/providers/types/persons';
7
+ import { BillingData, PersonData, SavedPassengerRecord, SavedPassengersData } from '@autobusal/providers/types/persons';
8
8
  import { FoundDay, ActionData, SaveData, OrderedData, CouponForm, AddonsSelection, AlternativesData, PriceAlertRequest, PriceAlertData } from './types';
9
9
 
10
10
  export const useGetDepartures = (data: RoutesSearchForm): UseQueryResult<FoundData[]> => (
@@ -313,3 +313,77 @@ export const usePostPriceAlert = (): UseMutationResult<PriceAlertData, AxiosErro
313
313
  retry: false
314
314
  })
315
315
  );
316
+
317
+ /**
318
+ * The travellers this account has saved, measured against THIS journey.
319
+ *
320
+ * Edited: Claude - Date: 2026-08-20
321
+ *
322
+ * The price ids are what make the answer journey-specific: obtapi puts a
323
+ * `missing` array on every row naming the fields these particular routes
324
+ * collect and this particular saved traveller has not got (see
325
+ * Account\PassengersController::browse). Without them the rows come back
326
+ * with no `missing` key at all, which is a different answer from an empty
327
+ * one - so they are always sent from here.
328
+ *
329
+ * `enabled` is the whole gate, and it is not an optimisation. The endpoint
330
+ * is `role:visitor`, so a guest or a member of staff gets a 404 - a correct
331
+ * refusal that confirms nothing, and one this UI must never turn into an
332
+ * error on somebody's checkout. Not asking is the only way to be sure of
333
+ * that; `retry: false` covers the case where the account changes underneath
334
+ * a query already in flight.
335
+ */
336
+ export const useGetSavedPassengers = (
337
+ departurePriceId: (number | undefined),
338
+ returnPriceId: (number | undefined),
339
+ enabled: boolean
340
+ ): UseQueryResult<SavedPassengersData> => (
341
+ useQuery({
342
+ queryKey: ['saved-passengers-checkout', { departurePriceId, returnPriceId }],
343
+ enabled: enabled && departurePriceId !== undefined,
344
+ retry: false,
345
+ queryFn: async () => (
346
+ await apiClient
347
+ .get('/api/account/passengers/browse', {
348
+ params: {
349
+ departure_price_id: departurePriceId,
350
+ return_price_id: returnPriceId
351
+ }
352
+ })
353
+ .then(response => (
354
+ response.data
355
+ ))
356
+ )
357
+ })
358
+ );
359
+
360
+ /**
361
+ * One saved traveller, passport included.
362
+ *
363
+ * Edited: Claude - Date: 2026-08-20
364
+ *
365
+ * A mutation for a GET, deliberately. This is not data the screen renders -
366
+ * it is fetched at the moment somebody picks a name out of the chooser, and
367
+ * what comes back is written into a form rather than displayed. A query
368
+ * would need the chosen id in render state and an effect to notice it
369
+ * landing; `mutate(id, { onSuccess })` is the same fetch with the answer
370
+ * arriving where the decision was made.
371
+ *
372
+ * This is also the ONLY request in the app that returns a stored passport
373
+ * number, which is why it is one traveller at a time and only after a
374
+ * customer has said who is sitting in this seat.
375
+ */
376
+ export const useGetSavedPassenger = (): UseMutationResult<SavedPassengerRecord, Error, number, unknown> => (
377
+ useMutation({
378
+ mutationKey: ['saved-passenger-record'],
379
+ mutationFn: async (id: number) => (
380
+ await apiClient
381
+ .get('/api/account/passengers/get', {
382
+ params: { id }
383
+ })
384
+ .then(response => (
385
+ response.data
386
+ ))
387
+ )
388
+ })
389
+ );