@autobusal/routes-order 1.33.1 → 1.33.2

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.33.2
4
+
5
+ ### Fixed
6
+
7
+ - **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.
8
+
9
+ 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.
10
+
11
+ - `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.
12
+
13
+ - 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.
14
+
3
15
  ## 1.33.1
4
16
 
5
17
  ### Added
@@ -49,6 +49,35 @@ interface Props {
49
49
  // Routes without a passenger_fields config keep the pre-feature form
50
50
  const DEFAULT_FIELDS = ['sex', 'passport'];
51
51
 
52
+ /**
53
+ * Edited: Claude - Date: 2026-08-21
54
+ *
55
+ * THE THREE COLUMNS A REGISTERED CUSTOMER CAN ARRIVE HERE UNABLE TO FILL,
56
+ * as `checkout field => the profile field that answers it`.
57
+ *
58
+ * `orders_contact`.`phone`/`address`/`city` are NOT NULL with no default;
59
+ * the same three on `visitors` are nullable, and registration collects a
60
+ * name, an email and a password and nothing else. So an incomplete profile
61
+ * is the ORDINARY state of a self-registered customer rather than an edge
62
+ * case - and until today it meant a 500 fired after the seats had already
63
+ * been held, and then (once that was patched) a booking handed to the
64
+ * operator with no phone number for the passenger.
65
+ *
66
+ * The customer is asked here instead, in the guest form's own fields, for
67
+ * the gaps and nothing else. Mirrors Orders\Make\Contact::BILLING and
68
+ * Orders\Make\Rules on the API side, which make exactly this set required
69
+ * for exactly this case.
70
+ */
71
+ const PROFILE_BILLING: Record<string, 'phone' | 'address' | 'city'> = {
72
+ bill_phone: 'phone',
73
+ bill_address: 'address',
74
+ bill_city: 'city'
75
+ };
76
+
77
+ const empty = (value?: string | null): boolean => (
78
+ value === undefined || value === null || String(value).trim() === ''
79
+ );
80
+
52
81
  /**
53
82
  * Passenger details and payment on one screen.
54
83
  *
@@ -98,6 +127,34 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
98
127
  const { data: UserData } = useUserStore();
99
128
  const { data: SettingsData } = useGetSettings();
100
129
 
130
+ /*
131
+ * Edited: Claude - Date: 2026-08-21
132
+ *
133
+ * WHAT THE BILLING BLOCK ASKS THIS BUYER FOR.
134
+ *
135
+ * A guest is asked for the whole thing, as always (`gaps` stays
136
+ * undefined, which is what Billing reads as "the whole form").
137
+ *
138
+ * A signed-in CUSTOMER is asked only for the profile columns that cannot
139
+ * answer for themselves - see PROFILE_BILLING above. Only a customer: an
140
+ * agent or an operator selling at the counter writes no `orders_contact`
141
+ * row at all, so there is nothing to ask them for.
142
+ *
143
+ * Gated on the profile actually being loaded. The user store persists
144
+ * only a display-only stub to localStorage (see stores/user.ts) and
145
+ * re-fetches the rest on mount, so `visitor` is briefly absent on a cold
146
+ * start - and treating "not loaded yet" as "empty" would flash three
147
+ * required fields at customers whose profile is complete. The wizard
148
+ * reaches this screen several navigations after the app mounts, by which
149
+ * time the bootstrap has long since landed.
150
+ */
151
+ const gaps = (UserData !== undefined && UserData.type === 'visitor' && UserData.visitor !== undefined)
152
+ ? Object.keys(PROFILE_BILLING).filter(field => empty(UserData.visitor?.[PROFILE_BILLING[field]]))
153
+ : undefined;
154
+
155
+ // the guest form, or a customer with something still to fill in
156
+ const askBilling = UserData === undefined || (gaps !== undefined && gaps.length > 0);
157
+
101
158
  const navigate = useNavigate();
102
159
 
103
160
  // one form serves both legs of a return trip, so it collects the UNION of
@@ -247,11 +304,18 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
247
304
  return;
248
305
  }
249
306
 
250
- // billing is only rendered for a guest, so there is nothing to validate
251
- // for a signed-in buyer - the API fills it from the account
307
+ /*
308
+ * Edited: Claude - Date: 2026-08-21
309
+ * The billing form is rendered for a guest (all of it) and for a
310
+ * customer whose profile has gaps (only those) - so it is validated
311
+ * whenever it was drawn, and skipped when it was not. It used to be
312
+ * skipped for every signed-in buyer, which is the assumption that let
313
+ * an incomplete profile reach the API with nothing to fill three NOT
314
+ * NULL columns with.
315
+ */
252
316
  let details = {} as BillingData;
253
317
 
254
- if (UserData === undefined) {
318
+ if (askBilling) {
255
319
  let collected: BillingData | undefined;
256
320
 
257
321
  await billing.handleSubmit((data) => { collected = data; })();
@@ -346,8 +410,8 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
346
410
  } }
347
411
  />
348
412
 
349
- { UserData === undefined && (
350
- <Billing t={ t } errors={ billing.formState.errors } refs={ billing.register } />
413
+ { askBilling && (
414
+ <Billing t={ t } errors={ billing.formState.errors } refs={ billing.register } only={ gaps } />
351
415
  ) }
352
416
  </Listing>
353
417
 
@@ -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
- <div className="column">
57
- <div className="row">
58
- <Required htmlFor={ id('bill_fn') }>{ t('routes_order.step5.billing.first_name') }</Required>
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
- <input type="text" id={ id('bill_fn') } { ...refs('bill_fn', Validate('required|min_length:2|max_length:100', t)) } />
96
+ <input type="text" id={ id('bill_fn') } { ...refs('bill_fn', Validate('required|min_length:2|max_length:100', t)) } />
61
97
 
62
- { Display(errors.bill_fn) }
63
- </div>
98
+ { Display(errors.bill_fn) }
99
+ </div>
64
100
 
65
- <div className="row">
66
- <Required htmlFor={ id('bill_ln') }>{ t('routes_order.step5.billing.last_name') }</Required>
101
+ <div className="row">
102
+ <Required htmlFor={ id('bill_ln') }>{ t('routes_order.step5.billing.last_name') }</Required>
67
103
 
68
- <input type="text" id={ id('bill_ln') } { ...refs('bill_ln', Validate('required|min_length:2|max_length:100', t)) } />
104
+ <input type="text" id={ id('bill_ln') } { ...refs('bill_ln', Validate('required|min_length:2|max_length:100', t)) } />
69
105
 
70
- { Display(errors.bill_ln) }
106
+ { Display(errors.bill_ln) }
107
+ </div>
71
108
  </div>
72
- </div>
109
+ ) }
73
110
 
74
111
  <div className="column">
75
- <div className="row">
76
- <Required htmlFor={ id('email') }>{ t('routes_order.step5.billing.email') }</Required>
112
+ { asked('email') && (
113
+ <div className="row">
114
+ <Required htmlFor={ id('email') }>{ t('routes_order.step5.billing.email') }</Required>
77
115
 
78
- <input type="email" id={ id('email') } { ...refs('email', Validate('required|email|max_length:255', t)) } />
116
+ <input type="email" id={ id('email') } { ...refs('email', Validate('required|email|max_length:255', t)) } />
79
117
 
80
- { Display(errors.email) }
118
+ { Display(errors.email) }
81
119
 
82
- <NoticeEmail>
83
- <AiOutlineInfoCircle /> { t('routes_order.step5.billing.email_info') }
84
- </NoticeEmail>
85
- </div>
120
+ <NoticeEmail>
121
+ <AiOutlineInfoCircle /> { t('routes_order.step5.billing.email_info') }
122
+ </NoticeEmail>
123
+ </div>
124
+ ) }
86
125
 
87
- <div className="row">
88
- <Required htmlFor={ id('bill_phone') }>{ t('routes_order.step5.billing.phone') }</Required>
126
+ { asked('bill_phone') && (
127
+ <div className="row">
128
+ <Required htmlFor={ id('bill_phone') }>{ t('routes_order.step5.billing.phone') }</Required>
89
129
 
90
- <input type="text" id={ id('bill_phone') } { ...refs('bill_phone', Validate('required|min_length:10|max_length:20', t)) } />
130
+ <input type="text" id={ id('bill_phone') } { ...refs('bill_phone', Validate('required|min_length:10|max_length:20', t)) } />
91
131
 
92
- { Display(errors.bill_phone) }
93
- </div>
132
+ { Display(errors.bill_phone) }
133
+ </div>
134
+ ) }
94
135
  </div>
95
136
 
96
- <div className="row">
97
- <Required htmlFor={ id('bill_address') }>{ t('routes_order.step5.billing.address') }</Required>
137
+ { asked('bill_address') && (
138
+ <div className="row">
139
+ <Required htmlFor={ id('bill_address') }>{ t('routes_order.step5.billing.address') }</Required>
98
140
 
99
- <textarea id={ id('bill_address') } { ...refs('bill_address', Validate('required|min_length:2', t)) }></textarea>
141
+ <textarea id={ id('bill_address') } { ...refs('bill_address', Validate(`required|min_length:${ gaps ? 5 : 2 }`, t)) }></textarea>
100
142
 
101
- { Display(errors.bill_address) }
102
- </div>
143
+ { Display(errors.bill_address) }
144
+ </div>
145
+ ) }
103
146
 
104
147
  <div className="column">
105
- <div className="row">
106
- <Required htmlFor={ id('bill_city') }>{ t('routes_order.step5.billing.city') }</Required>
148
+ { asked('bill_city') && (
149
+ <div className="row">
150
+ <Required htmlFor={ id('bill_city') }>{ t('routes_order.step5.billing.city') }</Required>
107
151
 
108
- <input type="text" id={ id('bill_city') } { ...refs('bill_city', Validate('required|min_length:2|max_length:100', t)) } />
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
- { Display(errors.bill_city) }
111
- </div>
154
+ { Display(errors.bill_city) }
155
+ </div>
156
+ ) }
112
157
 
113
- <div className="row">
114
- <Required htmlFor={ id('bill_country') }>{ t('routes_order.step5.billing.country') }</Required>
158
+ { asked('bill_country') && (
159
+ <div className="row">
160
+ <Required htmlFor={ id('bill_country') }>{ t('routes_order.step5.billing.country') }</Required>
115
161
 
116
- <select id={ id('bill_country') } { ...refs('bill_country', Validate('required|min:1', t)) }>
117
- <option value={ 0 }>{ t('routes_order.step5.billing.country_choose') }</option>
118
- { items }
119
- </select>
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
- { Display(errors.bill_country) }
122
- </div>
167
+ { Display(errors.bill_country) }
168
+ </div>
169
+ ) }
123
170
  </div>
124
171
 
125
- <div className="row">
126
- <label>
127
- <input type="checkbox" defaultChecked={ false } onClick={ (event: React.MouseEvent<HTMLInputElement>) => onAddCompany(event) } /> { t('routes_order.step5.billing.ask_company') }
128
- </label>
129
- </div>
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
- <div className="row">
154
- <label htmlFor={ id('comments') }>{ t('routes_order.step5.billing.comments') }</label>
202
+ { asked('comments') && (
203
+ <div className="row">
204
+ <label htmlFor={ id('comments') }>{ t('routes_order.step5.billing.comments') }</label>
155
205
 
156
- <textarea id={ id('comments') } { ...refs('comments') }></textarea>
157
- </div>
206
+ <textarea id={ id('comments') } { ...refs('comments') }></textarea>
207
+ </div>
208
+ ) }
158
209
 
159
- <div className="row row-nomargin">
160
- <label className="single">
161
- <input type="checkbox" value="1" { ...refs('terms', Validate('required', t)) } />&nbsp;
210
+ { asked('terms') && (
211
+ <div className="row row-nomargin">
212
+ <label className="single">
213
+ <input type="checkbox" value="1" { ...refs('terms', Validate('required', t)) } />&nbsp;
162
214
 
163
- <span dangerouslySetInnerHTML={{
164
- __html: t('routes_order.step5.billing.terms', {
165
- terms: `<a href="/page/terms" target="_blank">${ t('routes_order.step5.billing.terms_name') }</a>`
166
- })
167
- }} />
168
- </label>
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
- { Display(errors.terms) }
171
- </div>
222
+ { Display(errors.terms) }
223
+ </div>
224
+ ) }
172
225
  </Container>
173
226
  );
174
227
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.33.1",
3
+ "version": "1.33.2",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"