@autobusal/routes-order 1.33.1 → 1.33.3
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 +18 -0
- package/Checkout/Checkout.tsx +84 -5
- package/Step5/Billing/Billing.tsx +117 -64
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.33.3
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Checkout swallowed every booking error.** A 4xx from order creation - a seat taken while the form was open, the unpaid-hold limit, a route gone unavailable - left the buyer on the filled form with no message. It now surfaces the API's message. (Audit BOOK-13-c.)
|
|
8
|
+
|
|
9
|
+
## 1.33.2
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A signed-in customer with an incomplete profile is now asked for what is missing**, in the same billing fields a guest already fills, instead of reaching an API that had nothing to put in three NOT NULL columns. Only the gaps are asked for: a complete profile sees no change at all.
|
|
14
|
+
|
|
15
|
+
This is not a rare state. A self-registered visitor has a name and nothing else, and the form that collects phone, address and city is reachable only by admin-created accounts — so the customer could not have completed it in advance even if asked to.
|
|
16
|
+
|
|
17
|
+
- `Billing` takes an optional `only` list; undefined keeps the whole guest block exactly as it was. Field rules follow whichever profile the value will be stored on, so the form never accepts more than the API will keep.
|
|
18
|
+
|
|
19
|
+
- Checkout treats "profile not loaded yet" as unknown rather than empty — the user store persists a redacted stub and re-fetches on mount, so the other reading would flash required fields at customers whose profile is complete.
|
|
20
|
+
|
|
3
21
|
## 1.33.1
|
|
4
22
|
|
|
5
23
|
### Added
|
package/Checkout/Checkout.tsx
CHANGED
|
@@ -27,6 +27,7 @@ import { useGetSettings } from '@autobusal/providers/services';
|
|
|
27
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
|
+
import { error as notifyError } from '@autobusal/utilities';
|
|
30
31
|
|
|
31
32
|
interface Props {
|
|
32
33
|
values?: PersonData
|
|
@@ -49,6 +50,35 @@ interface Props {
|
|
|
49
50
|
// Routes without a passenger_fields config keep the pre-feature form
|
|
50
51
|
const DEFAULT_FIELDS = ['sex', 'passport'];
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Edited: Claude - Date: 2026-08-21
|
|
55
|
+
*
|
|
56
|
+
* THE THREE COLUMNS A REGISTERED CUSTOMER CAN ARRIVE HERE UNABLE TO FILL,
|
|
57
|
+
* as `checkout field => the profile field that answers it`.
|
|
58
|
+
*
|
|
59
|
+
* `orders_contact`.`phone`/`address`/`city` are NOT NULL with no default;
|
|
60
|
+
* the same three on `visitors` are nullable, and registration collects a
|
|
61
|
+
* name, an email and a password and nothing else. So an incomplete profile
|
|
62
|
+
* is the ORDINARY state of a self-registered customer rather than an edge
|
|
63
|
+
* case - and until today it meant a 500 fired after the seats had already
|
|
64
|
+
* been held, and then (once that was patched) a booking handed to the
|
|
65
|
+
* operator with no phone number for the passenger.
|
|
66
|
+
*
|
|
67
|
+
* The customer is asked here instead, in the guest form's own fields, for
|
|
68
|
+
* the gaps and nothing else. Mirrors Orders\Make\Contact::BILLING and
|
|
69
|
+
* Orders\Make\Rules on the API side, which make exactly this set required
|
|
70
|
+
* for exactly this case.
|
|
71
|
+
*/
|
|
72
|
+
const PROFILE_BILLING: Record<string, 'phone' | 'address' | 'city'> = {
|
|
73
|
+
bill_phone: 'phone',
|
|
74
|
+
bill_address: 'address',
|
|
75
|
+
bill_city: 'city'
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const empty = (value?: string | null): boolean => (
|
|
79
|
+
value === undefined || value === null || String(value).trim() === ''
|
|
80
|
+
);
|
|
81
|
+
|
|
52
82
|
/**
|
|
53
83
|
* Passenger details and payment on one screen.
|
|
54
84
|
*
|
|
@@ -98,6 +128,34 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
98
128
|
const { data: UserData } = useUserStore();
|
|
99
129
|
const { data: SettingsData } = useGetSettings();
|
|
100
130
|
|
|
131
|
+
/*
|
|
132
|
+
* Edited: Claude - Date: 2026-08-21
|
|
133
|
+
*
|
|
134
|
+
* WHAT THE BILLING BLOCK ASKS THIS BUYER FOR.
|
|
135
|
+
*
|
|
136
|
+
* A guest is asked for the whole thing, as always (`gaps` stays
|
|
137
|
+
* undefined, which is what Billing reads as "the whole form").
|
|
138
|
+
*
|
|
139
|
+
* A signed-in CUSTOMER is asked only for the profile columns that cannot
|
|
140
|
+
* answer for themselves - see PROFILE_BILLING above. Only a customer: an
|
|
141
|
+
* agent or an operator selling at the counter writes no `orders_contact`
|
|
142
|
+
* row at all, so there is nothing to ask them for.
|
|
143
|
+
*
|
|
144
|
+
* Gated on the profile actually being loaded. The user store persists
|
|
145
|
+
* only a display-only stub to localStorage (see stores/user.ts) and
|
|
146
|
+
* re-fetches the rest on mount, so `visitor` is briefly absent on a cold
|
|
147
|
+
* start - and treating "not loaded yet" as "empty" would flash three
|
|
148
|
+
* required fields at customers whose profile is complete. The wizard
|
|
149
|
+
* reaches this screen several navigations after the app mounts, by which
|
|
150
|
+
* time the bootstrap has long since landed.
|
|
151
|
+
*/
|
|
152
|
+
const gaps = (UserData !== undefined && UserData.type === 'visitor' && UserData.visitor !== undefined)
|
|
153
|
+
? Object.keys(PROFILE_BILLING).filter(field => empty(UserData.visitor?.[PROFILE_BILLING[field]]))
|
|
154
|
+
: undefined;
|
|
155
|
+
|
|
156
|
+
// the guest form, or a customer with something still to fill in
|
|
157
|
+
const askBilling = UserData === undefined || (gaps !== undefined && gaps.length > 0);
|
|
158
|
+
|
|
101
159
|
const navigate = useNavigate();
|
|
102
160
|
|
|
103
161
|
// one form serves both legs of a return trip, so it collects the UNION of
|
|
@@ -247,11 +305,18 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
247
305
|
return;
|
|
248
306
|
}
|
|
249
307
|
|
|
250
|
-
|
|
251
|
-
|
|
308
|
+
/*
|
|
309
|
+
* Edited: Claude - Date: 2026-08-21
|
|
310
|
+
* The billing form is rendered for a guest (all of it) and for a
|
|
311
|
+
* customer whose profile has gaps (only those) - so it is validated
|
|
312
|
+
* whenever it was drawn, and skipped when it was not. It used to be
|
|
313
|
+
* skipped for every signed-in buyer, which is the assumption that let
|
|
314
|
+
* an incomplete profile reach the API with nothing to fill three NOT
|
|
315
|
+
* NULL columns with.
|
|
316
|
+
*/
|
|
252
317
|
let details = {} as BillingData;
|
|
253
318
|
|
|
254
|
-
if (
|
|
319
|
+
if (askBilling) {
|
|
255
320
|
let collected: BillingData | undefined;
|
|
256
321
|
|
|
257
322
|
await billing.handleSubmit((data) => { collected = data; })();
|
|
@@ -287,6 +352,20 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
287
352
|
);
|
|
288
353
|
|
|
289
354
|
navigate(`/orders/process/${ action }/${ hash }`);
|
|
355
|
+
},
|
|
356
|
+
/*
|
|
357
|
+
* Edited: Claude - Date: 2026-08-22 (audit finding BOOK-13-c)
|
|
358
|
+
*
|
|
359
|
+
* Surface why the booking was refused. Every 4xx from order creation -
|
|
360
|
+
* a seat taken while the form was open, the unpaid-hold limit, a route
|
|
361
|
+
* that just went unavailable - left the buyer on the filled form with
|
|
362
|
+
* no message, clicking Pay again. The API's own message says exactly
|
|
363
|
+
* what happened.
|
|
364
|
+
*/
|
|
365
|
+
onError: (submitError: unknown) => {
|
|
366
|
+
const message = (submitError as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
367
|
+
|
|
368
|
+
notifyError(message ?? t('routes_order.checkout.error', { ns: 'common', defaultValue: 'We could not complete your booking. Please try again.' }));
|
|
290
369
|
}
|
|
291
370
|
});
|
|
292
371
|
};
|
|
@@ -346,8 +425,8 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
346
425
|
} }
|
|
347
426
|
/>
|
|
348
427
|
|
|
349
|
-
{
|
|
350
|
-
<Billing t={ t } errors={ billing.formState.errors } refs={ billing.register } />
|
|
428
|
+
{ askBilling && (
|
|
429
|
+
<Billing t={ t } errors={ billing.formState.errors } refs={ billing.register } only={ gaps } />
|
|
351
430
|
) }
|
|
352
431
|
</Listing>
|
|
353
432
|
|
|
@@ -15,11 +15,46 @@ interface Props {
|
|
|
15
15
|
t: TFunction<'common'>
|
|
16
16
|
errors: FieldErrors
|
|
17
17
|
refs: UseFormRegister<BillingData>
|
|
18
|
+
/**
|
|
19
|
+
* Edited: Claude - Date: 2026-08-21
|
|
20
|
+
*
|
|
21
|
+
* Which fields to ask for. Undefined is the whole block, which is what a
|
|
22
|
+
* guest has always been shown and stays the default.
|
|
23
|
+
*
|
|
24
|
+
* A SIGNED-IN CUSTOMER gets a list instead, holding only the fields their
|
|
25
|
+
* profile cannot answer. `orders_contact`.`phone`/`address`/`city` are
|
|
26
|
+
* NOT NULL, the same three on `visitors` are nullable, and registration
|
|
27
|
+
* never collects them - so a customer could reach this screen unable to
|
|
28
|
+
* fill three columns that refuse to be empty, which the API used to
|
|
29
|
+
* answer with a 500 and then with a booking carrying no phone number.
|
|
30
|
+
* They are asked here instead, in the same boxes and under the same
|
|
31
|
+
* labels a guest sees, rather than being sent out of the checkout to
|
|
32
|
+
* their account page and back.
|
|
33
|
+
*/
|
|
34
|
+
only?: string[]
|
|
18
35
|
}
|
|
19
36
|
|
|
20
|
-
const Billing = ({ errors, t, refs }: Props): JSX.Element => {
|
|
37
|
+
const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
21
38
|
const [ company, setCompany ] = useState<boolean>(false);
|
|
22
39
|
|
|
40
|
+
// A field is asked for when nothing narrowed the block, or when it is on
|
|
41
|
+
// the list of gaps.
|
|
42
|
+
const asked = (field: string): boolean => only === undefined || only.includes(field);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Edited: Claude - Date: 2026-08-21
|
|
46
|
+
*
|
|
47
|
+
* Two of these fields are validated differently depending on WHO is
|
|
48
|
+
* filling them in, and the difference is not cosmetic - it mirrors the
|
|
49
|
+
* two sets of rules the API applies. A guest's answers go to
|
|
50
|
+
* `orders_contact` and nowhere else; a signed-in customer's are also
|
|
51
|
+
* written back onto their `visitors` profile, whose `city` column holds
|
|
52
|
+
* 40 characters and whose address rule has always asked for 5. A form
|
|
53
|
+
* that accepted more than the API will store would just move the refusal
|
|
54
|
+
* to after the pay button.
|
|
55
|
+
*/
|
|
56
|
+
const gaps = only !== undefined;
|
|
57
|
+
|
|
23
58
|
/**
|
|
24
59
|
* Edited: Claude - Date: 2026-08-19
|
|
25
60
|
* Same fix as the passenger block: the labels here were text sitting
|
|
@@ -53,82 +88,96 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
|
|
|
53
88
|
{ t('routes_order.step5.billing.title') }
|
|
54
89
|
</SubTitle>
|
|
55
90
|
|
|
56
|
-
|
|
57
|
-
<div className="
|
|
58
|
-
<
|
|
91
|
+
{ asked('bill_fn') && (
|
|
92
|
+
<div className="column">
|
|
93
|
+
<div className="row">
|
|
94
|
+
<Required htmlFor={ id('bill_fn') }>{ t('routes_order.step5.billing.first_name') }</Required>
|
|
59
95
|
|
|
60
|
-
|
|
96
|
+
<input type="text" id={ id('bill_fn') } { ...refs('bill_fn', Validate('required|min_length:2|max_length:100', t)) } />
|
|
61
97
|
|
|
62
|
-
|
|
63
|
-
|
|
98
|
+
{ Display(errors.bill_fn) }
|
|
99
|
+
</div>
|
|
64
100
|
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
<div className="row">
|
|
102
|
+
<Required htmlFor={ id('bill_ln') }>{ t('routes_order.step5.billing.last_name') }</Required>
|
|
67
103
|
|
|
68
|
-
|
|
104
|
+
<input type="text" id={ id('bill_ln') } { ...refs('bill_ln', Validate('required|min_length:2|max_length:100', t)) } />
|
|
69
105
|
|
|
70
|
-
|
|
106
|
+
{ Display(errors.bill_ln) }
|
|
107
|
+
</div>
|
|
71
108
|
</div>
|
|
72
|
-
|
|
109
|
+
) }
|
|
73
110
|
|
|
74
111
|
<div className="column">
|
|
75
|
-
|
|
76
|
-
<
|
|
112
|
+
{ asked('email') && (
|
|
113
|
+
<div className="row">
|
|
114
|
+
<Required htmlFor={ id('email') }>{ t('routes_order.step5.billing.email') }</Required>
|
|
77
115
|
|
|
78
|
-
|
|
116
|
+
<input type="email" id={ id('email') } { ...refs('email', Validate('required|email|max_length:255', t)) } />
|
|
79
117
|
|
|
80
|
-
|
|
118
|
+
{ Display(errors.email) }
|
|
81
119
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
<NoticeEmail>
|
|
121
|
+
<AiOutlineInfoCircle /> { t('routes_order.step5.billing.email_info') }
|
|
122
|
+
</NoticeEmail>
|
|
123
|
+
</div>
|
|
124
|
+
) }
|
|
86
125
|
|
|
87
|
-
|
|
88
|
-
<
|
|
126
|
+
{ asked('bill_phone') && (
|
|
127
|
+
<div className="row">
|
|
128
|
+
<Required htmlFor={ id('bill_phone') }>{ t('routes_order.step5.billing.phone') }</Required>
|
|
89
129
|
|
|
90
|
-
|
|
130
|
+
<input type="text" id={ id('bill_phone') } { ...refs('bill_phone', Validate('required|min_length:10|max_length:20', t)) } />
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
{ Display(errors.bill_phone) }
|
|
133
|
+
</div>
|
|
134
|
+
) }
|
|
94
135
|
</div>
|
|
95
136
|
|
|
96
|
-
|
|
97
|
-
<
|
|
137
|
+
{ asked('bill_address') && (
|
|
138
|
+
<div className="row">
|
|
139
|
+
<Required htmlFor={ id('bill_address') }>{ t('routes_order.step5.billing.address') }</Required>
|
|
98
140
|
|
|
99
|
-
|
|
141
|
+
<textarea id={ id('bill_address') } { ...refs('bill_address', Validate(`required|min_length:${ gaps ? 5 : 2 }`, t)) }></textarea>
|
|
100
142
|
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
{ Display(errors.bill_address) }
|
|
144
|
+
</div>
|
|
145
|
+
) }
|
|
103
146
|
|
|
104
147
|
<div className="column">
|
|
105
|
-
|
|
106
|
-
<
|
|
148
|
+
{ asked('bill_city') && (
|
|
149
|
+
<div className="row">
|
|
150
|
+
<Required htmlFor={ id('bill_city') }>{ t('routes_order.step5.billing.city') }</Required>
|
|
107
151
|
|
|
108
|
-
|
|
152
|
+
<input type="text" id={ id('bill_city') } { ...refs('bill_city', Validate(`required|min_length:2|max_length:${ gaps ? 40 : 100 }`, t)) } />
|
|
109
153
|
|
|
110
|
-
|
|
111
|
-
|
|
154
|
+
{ Display(errors.bill_city) }
|
|
155
|
+
</div>
|
|
156
|
+
) }
|
|
112
157
|
|
|
113
|
-
|
|
114
|
-
<
|
|
158
|
+
{ asked('bill_country') && (
|
|
159
|
+
<div className="row">
|
|
160
|
+
<Required htmlFor={ id('bill_country') }>{ t('routes_order.step5.billing.country') }</Required>
|
|
115
161
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
162
|
+
<select id={ id('bill_country') } { ...refs('bill_country', Validate('required|min:1', t)) }>
|
|
163
|
+
<option value={ 0 }>{ t('routes_order.step5.billing.country_choose') }</option>
|
|
164
|
+
{ items }
|
|
165
|
+
</select>
|
|
120
166
|
|
|
121
|
-
|
|
122
|
-
|
|
167
|
+
{ Display(errors.bill_country) }
|
|
168
|
+
</div>
|
|
169
|
+
) }
|
|
123
170
|
</div>
|
|
124
171
|
|
|
125
|
-
|
|
126
|
-
<
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
{ asked('bill_company') && (
|
|
173
|
+
<div className="row">
|
|
174
|
+
<label>
|
|
175
|
+
<input type="checkbox" defaultChecked={ false } onClick={ (event: React.MouseEvent<HTMLInputElement>) => onAddCompany(event) } /> { t('routes_order.step5.billing.ask_company') }
|
|
176
|
+
</label>
|
|
177
|
+
</div>
|
|
178
|
+
) }
|
|
130
179
|
|
|
131
|
-
{ company && (
|
|
180
|
+
{ asked('bill_company') && company && (
|
|
132
181
|
<div className="column">
|
|
133
182
|
<div className="row">
|
|
134
183
|
{ /* not Required - these are optional, so a plain <label>
|
|
@@ -150,25 +199,29 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
|
|
|
150
199
|
</div>
|
|
151
200
|
) }
|
|
152
201
|
|
|
153
|
-
|
|
154
|
-
<
|
|
202
|
+
{ asked('comments') && (
|
|
203
|
+
<div className="row">
|
|
204
|
+
<label htmlFor={ id('comments') }>{ t('routes_order.step5.billing.comments') }</label>
|
|
155
205
|
|
|
156
|
-
|
|
157
|
-
|
|
206
|
+
<textarea id={ id('comments') } { ...refs('comments') }></textarea>
|
|
207
|
+
</div>
|
|
208
|
+
) }
|
|
158
209
|
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
<
|
|
210
|
+
{ asked('terms') && (
|
|
211
|
+
<div className="row row-nomargin">
|
|
212
|
+
<label className="single">
|
|
213
|
+
<input type="checkbox" value="1" { ...refs('terms', Validate('required', t)) } />
|
|
162
214
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
215
|
+
<span dangerouslySetInnerHTML={{
|
|
216
|
+
__html: t('routes_order.step5.billing.terms', {
|
|
217
|
+
terms: `<a href="/page/terms" target="_blank">${ t('routes_order.step5.billing.terms_name') }</a>`
|
|
218
|
+
})
|
|
219
|
+
}} />
|
|
220
|
+
</label>
|
|
169
221
|
|
|
170
|
-
|
|
171
|
-
|
|
222
|
+
{ Display(errors.terms) }
|
|
223
|
+
</div>
|
|
224
|
+
) }
|
|
172
225
|
</Container>
|
|
173
226
|
);
|
|
174
227
|
};
|