@autobusal/routes-order 1.31.3 → 1.31.5

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.31.5
4
+
5
+ ### Added
6
+
7
+ - **BusTrip carries the pair's AggregateRating** when `/routes/search/facts` reports one (`FactsData.rating`). The aggregate is computed server-side over the same published-review rules the reviews listing uses, and rides the facts cache - the pair page keeps its one-fetch design. Absent rating, the key is not emitted at all.
8
+
9
+ ## 1.31.4
10
+
11
+ ### Fixed
12
+
13
+ - **The checkout form's fields have accessible names.** Passenger and billing inputs had no `id`/`for` pairing, no `aria-label` and no wrapping label - programmatically anonymous on the one form that decides whether a sale completes. Ids are `useId`-prefixed, which is what stops `adult1_*`, `adult2_*` and `child1_*` colliding; a hardcoded id would have traded one accessibility bug for another.
14
+ - Coupon code and the add-on phone fields get an `aria-label`, having only ever had a placeholder - and a placeholder disappears the moment someone types.
15
+
3
16
  ## 1.31.3
4
17
 
5
18
  ### Fixed
package/Facts/types.ts CHANGED
@@ -17,6 +17,13 @@ export interface FactsData {
17
17
  // how many coaches serve the pair in the TIMETABLE, not on a given date
18
18
  departures: number
19
19
  operators: string[]
20
+ // Edited: Claude - Date: 2026-08-20
21
+ // The pair's own review score - the published reviews of every route in
22
+ // this very timetable, aggregated by obtapi with the same threshold rule
23
+ // as everything else (null until there is enough to say something honest).
24
+ // For structured data; note ScheduleRow.rating below is the OPERATOR's
25
+ // company-wide number, a different thing.
26
+ rating: { average: number, count: number } | null
20
27
  // Two numbers that must never be conflated.
21
28
  //
22
29
  // `road` is real driving distance from a routing service and is null unless
@@ -27,6 +27,19 @@ interface Props {
27
27
  * claiming travellers rated this journey when they rated the company. When
28
28
  * an operator has no published rating - below the display threshold - it is
29
29
  * simply absent, exactly as it is on the page.
30
+ *
31
+ * Edited: Claude - Date: 2026-08-20
32
+ *
33
+ * The trip now ALSO carries its own AggregateRating - and that is not the
34
+ * claim the paragraph above forbids, because `facts.rating` is a different
35
+ * number: obtapi aggregates the published reviews of exactly the routes in
36
+ * this timetable (Facts::rating, the same set Reviews\BrowseController's
37
+ * `pair` mode reads), not a company-wide score stretched over one leg.
38
+ * Travellers who booked these very coaches wrote those reviews, and every
39
+ * card on the page links to the operator profile where they are read in
40
+ * full. Below the display threshold obtapi sends null and nothing is
41
+ * emitted here - an AggregateRating of nothing is a guideline violation,
42
+ * worse than none.
30
43
  */
31
44
  const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
32
45
  const schedule = facts.schedule ?? [];
@@ -62,6 +75,19 @@ const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
62
75
  departureBusStop: { '@type': 'BusStop', name: facts.from },
63
76
  arrivalBusStop: { '@type': 'BusStop', name: facts.to },
64
77
 
78
+ // Edited: Claude - Date: 2026-08-20
79
+ // The pair's own score - see the header comment. Null below the
80
+ // display threshold, and then the property simply does not exist.
81
+ ...(facts.rating ? {
82
+ aggregateRating: {
83
+ '@type': 'AggregateRating',
84
+ ratingValue: facts.rating.average,
85
+ reviewCount: facts.rating.count,
86
+ bestRating: 5,
87
+ worstRating: 1
88
+ }
89
+ } : {}),
90
+
65
91
  ...(providers.length > 0 ? {
66
92
  provider: providers.map(item => organisation(item.name, item.rating))
67
93
  } : {}),
@@ -1,3 +1,4 @@
1
+ import { useId } from 'react';
1
2
  import { TFunction } from 'i18next';
2
3
  import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form';
3
4
  import { Calendar, Gender, ChooseSeat, Required } from '@autobusal/common';
@@ -47,6 +48,20 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
47
48
 
48
49
  const has = (field: string): boolean => fields.includes(field);
49
50
 
51
+ /**
52
+ * Edited: Claude - Date: 2026-08-19
53
+ * Every field here was rendered as a bare <input> beside its label text,
54
+ * with no id and no association - so a screen reader announced the whole
55
+ * passenger block as a run of unnamed "edit text". The field NAMES are
56
+ * already unique per passenger (adult1_fn, adult2_fn), but they belong to
57
+ * the form payload; ids are page-global, so they get a useId prefix -
58
+ * this component renders once per passenger, and two forms on one page
59
+ * would otherwise emit duplicate ids, which is its own defect.
60
+ */
61
+ const uid = useId();
62
+
63
+ const id = (field: string): string => `${ uid }${ field }`;
64
+
50
65
  return (
51
66
  <Container className="box">
52
67
  <Title>
@@ -55,10 +70,11 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
55
70
 
56
71
  <div className="column">
57
72
  <div className="row">
58
- <Required>{ t('routes_order.step4.first_name') }</Required>
73
+ <Required htmlFor={ id(names.firstName) }>{ t('routes_order.step4.first_name') }</Required>
59
74
 
60
75
  <input
61
76
  type="text"
77
+ id={ id(names.firstName) }
62
78
  defaultValue={ values !== undefined ? values[names.firstName] : '' }
63
79
  { ...refs(names.firstName, Validate('required|min_length:2|max_length:100', t)) }
64
80
  />
@@ -67,10 +83,11 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
67
83
  </div>
68
84
 
69
85
  <div className="row">
70
- <Required>{ t('routes_order.step4.last_name') }</Required>
86
+ <Required htmlFor={ id(names.lastName) }>{ t('routes_order.step4.last_name') }</Required>
71
87
 
72
88
  <input
73
89
  type="text"
90
+ id={ id(names.lastName) }
74
91
  defaultValue={ values !== undefined ? values[names.lastName] : '' }
75
92
  { ...refs(names.lastName, Validate('required|min_length:2|max_length:100', t)) }
76
93
  />
@@ -81,11 +98,12 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
81
98
 
82
99
  <div className="column">
83
100
  <div className="row">
84
- <Required>{ t('routes_order.step4.dob') }</Required>
101
+ <Required htmlFor={ id(names.dob) }>{ t('routes_order.step4.dob') }</Required>
85
102
 
86
103
  <Calendar
87
104
  type="dob"
88
105
  name={ names.dob }
106
+ id={ id(names.dob) }
89
107
  defaultValue={ values !== undefined ? String(values[names.dob]) : undefined }
90
108
  t={ t }
91
109
  validation="required"
@@ -116,10 +134,11 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
116
134
  <div className="column">
117
135
  { has('passport') && (
118
136
  <div className="row">
119
- <Required>{ t('routes_order.step4.passport') }</Required>
137
+ <Required htmlFor={ id(names.passport) }>{ t('routes_order.step4.passport') }</Required>
120
138
 
121
139
  <input
122
140
  type="text"
141
+ id={ id(names.passport) }
123
142
  defaultValue={ values !== undefined ? values[names.passport] : '' }
124
143
  { ...refs(names.passport, Validate('required|min_length:2|max_length:50', t)) }
125
144
  />
@@ -130,10 +149,11 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
130
149
 
131
150
  { has('phone') && (
132
151
  <div className="row">
133
- <Required>{ t('routes_order.step4.phone') }</Required>
152
+ <Required htmlFor={ id(names.phone) }>{ t('routes_order.step4.phone') }</Required>
134
153
 
135
154
  <input
136
155
  type="text"
156
+ id={ id(names.phone) }
137
157
  defaultValue={ values !== undefined ? values[names.phone] : '' }
138
158
  { ...refs(names.phone, Validate('required|min_length:5|max_length:50', t)) }
139
159
  />
@@ -156,10 +176,11 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
156
176
  <div className="column">
157
177
  { has('whatsapp') && (
158
178
  <div className="row">
159
- <Required>{ t('routes_order.step4.whatsapp') }</Required>
179
+ <Required htmlFor={ id(names.whatsapp) }>{ t('routes_order.step4.whatsapp') }</Required>
160
180
 
161
181
  <input
162
182
  type="text"
183
+ id={ id(names.whatsapp) }
163
184
  defaultValue={ values !== undefined ? values[names.whatsapp] : '' }
164
185
  { ...refs(names.whatsapp, Validate('required|min_length:5|max_length:50', t)) }
165
186
  />
@@ -170,10 +191,11 @@ const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, p
170
191
 
171
192
  { has('telegram') && (
172
193
  <div className="row">
173
- <Required>{ t('routes_order.step4.telegram') }</Required>
194
+ <Required htmlFor={ id(names.telegram) }>{ t('routes_order.step4.telegram') }</Required>
174
195
 
175
196
  <input
176
197
  type="text"
198
+ id={ id(names.telegram) }
177
199
  defaultValue={ values !== undefined ? values[names.telegram] : '' }
178
200
  { ...refs(names.telegram, Validate('required|min_length:2|max_length:100', t)) }
179
201
  />
@@ -85,9 +85,15 @@ const Addons = ({ data, flexOffer, currency, whatsapp, whatsappNumber, telegram,
85
85
 
86
86
  <Notice>{ t('routes_order.step5.addons.whatsapp.description') }</Notice>
87
87
 
88
+ { /* Edited: Claude - Date: 2026-08-19
89
+ The number fields below have no visible label of their own -
90
+ they appear under the checkbox they belong to - so the
91
+ placeholder wording is repeated as an accessible name rather
92
+ than leaving them unnamed. */ }
88
93
  { whatsapp && (
89
94
  <input
90
95
  type="text"
96
+ aria-label={ t('routes_order.step5.addons.whatsapp.phone_placeholder') }
91
97
  placeholder={ t('routes_order.step5.addons.whatsapp.phone_placeholder') }
92
98
  value={ whatsappNumber }
93
99
  onChange={ (event) => onChangeWhatsappNumber(event.target.value) }
@@ -143,6 +149,7 @@ const Addons = ({ data, flexOffer, currency, whatsapp, whatsappNumber, telegram,
143
149
  { sms && (
144
150
  <input
145
151
  type="text"
152
+ aria-label={ t('routes_order.step5.addons.sms.phone_placeholder') }
146
153
  placeholder={ t('routes_order.step5.addons.sms.phone_placeholder') }
147
154
  value={ smsNumber }
148
155
  onChange={ (event) => onChangeSmsNumber(event.target.value) }
@@ -1,4 +1,4 @@
1
- import { useState } from 'react';
1
+ import { useState, useId } from 'react';
2
2
  import { TFunction } from 'i18next';
3
3
  import { Required } from '@autobusal/common';
4
4
  import { UseFormRegister, FieldErrors } from 'react-hook-form';
@@ -20,6 +20,18 @@ interface Props {
20
20
  const Billing = ({ errors, t, refs }: Props): JSX.Element => {
21
21
  const [ company, setCompany ] = useState<boolean>(false);
22
22
 
23
+ /**
24
+ * Edited: Claude - Date: 2026-08-19
25
+ * Same fix as the passenger block: the labels here were text sitting
26
+ * beside the field rather than attached to it, so the billing form - the
27
+ * last thing between a buyer and paying - announced as unnamed edit
28
+ * boxes. Prefixed with useId rather than using the field name directly,
29
+ * because ids are page-global and these names are not guaranteed to be.
30
+ */
31
+ const uid = useId();
32
+
33
+ const id = (field: string): string => `${ uid }${ field }`;
34
+
23
35
  const countries = useCountries('all');
24
36
 
25
37
  if (countries.isLoading) {
@@ -43,17 +55,17 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
43
55
 
44
56
  <div className="column">
45
57
  <div className="row">
46
- <Required>{ t('routes_order.step5.billing.first_name') }</Required>
58
+ <Required htmlFor={ id('bill_fn') }>{ t('routes_order.step5.billing.first_name') }</Required>
47
59
 
48
- <input type="text" { ...refs('bill_fn', Validate('required|min_length:2|max_length:100', t)) } />
60
+ <input type="text" id={ id('bill_fn') } { ...refs('bill_fn', Validate('required|min_length:2|max_length:100', t)) } />
49
61
 
50
62
  { Display(errors.bill_fn) }
51
63
  </div>
52
64
 
53
65
  <div className="row">
54
- <Required>{ t('routes_order.step5.billing.last_name') }</Required>
66
+ <Required htmlFor={ id('bill_ln') }>{ t('routes_order.step5.billing.last_name') }</Required>
55
67
 
56
- <input type="text" { ...refs('bill_ln', Validate('required|min_length:2|max_length:100', t)) } />
68
+ <input type="text" id={ id('bill_ln') } { ...refs('bill_ln', Validate('required|min_length:2|max_length:100', t)) } />
57
69
 
58
70
  { Display(errors.bill_ln) }
59
71
  </div>
@@ -61,9 +73,9 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
61
73
 
62
74
  <div className="column">
63
75
  <div className="row">
64
- <Required>{ t('routes_order.step5.billing.email') }</Required>
65
-
66
- <input type="email" { ...refs('email', Validate('required|email|max_length:255', t)) } />
76
+ <Required htmlFor={ id('email') }>{ t('routes_order.step5.billing.email') }</Required>
77
+
78
+ <input type="email" id={ id('email') } { ...refs('email', Validate('required|email|max_length:255', t)) } />
67
79
 
68
80
  { Display(errors.email) }
69
81
 
@@ -73,35 +85,35 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
73
85
  </div>
74
86
 
75
87
  <div className="row">
76
- <Required>{ t('routes_order.step5.billing.phone') }</Required>
88
+ <Required htmlFor={ id('bill_phone') }>{ t('routes_order.step5.billing.phone') }</Required>
77
89
 
78
- <input type="text" { ...refs('bill_phone', Validate('required|min_length:10|max_length:20', t)) } />
90
+ <input type="text" id={ id('bill_phone') } { ...refs('bill_phone', Validate('required|min_length:10|max_length:20', t)) } />
79
91
 
80
92
  { Display(errors.bill_phone) }
81
93
  </div>
82
94
  </div>
83
95
 
84
96
  <div className="row">
85
- <Required>{ t('routes_order.step5.billing.address') }</Required>
97
+ <Required htmlFor={ id('bill_address') }>{ t('routes_order.step5.billing.address') }</Required>
86
98
 
87
- <textarea { ...refs('bill_address', Validate('required|min_length:2', t)) }></textarea>
99
+ <textarea id={ id('bill_address') } { ...refs('bill_address', Validate('required|min_length:2', t)) }></textarea>
88
100
 
89
101
  { Display(errors.bill_address) }
90
102
  </div>
91
103
 
92
104
  <div className="column">
93
105
  <div className="row">
94
- <Required>{ t('routes_order.step5.billing.city') }</Required>
106
+ <Required htmlFor={ id('bill_city') }>{ t('routes_order.step5.billing.city') }</Required>
95
107
 
96
- <input type="text" { ...refs('bill_city', Validate('required|min_length:2|max_length:100', t)) } />
108
+ <input type="text" id={ id('bill_city') } { ...refs('bill_city', Validate('required|min_length:2|max_length:100', t)) } />
97
109
 
98
110
  { Display(errors.bill_city) }
99
111
  </div>
100
112
 
101
113
  <div className="row">
102
- <Required>{ t('routes_order.step5.billing.country') }</Required>
114
+ <Required htmlFor={ id('bill_country') }>{ t('routes_order.step5.billing.country') }</Required>
103
115
 
104
- <select { ...refs('bill_country', Validate('required|min:1', t)) }>
116
+ <select id={ id('bill_country') } { ...refs('bill_country', Validate('required|min:1', t)) }>
105
117
  <option value={ 0 }>{ t('routes_order.step5.billing.country_choose') }</option>
106
118
  { items }
107
119
  </select>
@@ -119,17 +131,19 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
119
131
  { company && (
120
132
  <div className="column">
121
133
  <div className="row">
122
- { t('routes_order.step5.billing.company') }
134
+ { /* not Required - these are optional, so a plain <label>
135
+ rather than the asterisked one, but still associated */ }
136
+ <label htmlFor={ id('bill_company') }>{ t('routes_order.step5.billing.company') }</label>
123
137
 
124
- <input type="text" { ...refs('bill_company') } />
138
+ <input type="text" id={ id('bill_company') } { ...refs('bill_company') } />
125
139
 
126
140
  { Display(errors.bill_company) }
127
141
  </div>
128
142
 
129
143
  <div className="row">
130
- { t('routes_order.step5.billing.nipt') }
144
+ <label htmlFor={ id('bill_nipt') }>{ t('routes_order.step5.billing.nipt') }</label>
131
145
 
132
- <input type="text" { ...refs('bill_nipt') } />
146
+ <input type="text" id={ id('bill_nipt') } { ...refs('bill_nipt') } />
133
147
 
134
148
  { Display(errors.bill_nipt) }
135
149
  </div>
@@ -137,9 +151,9 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
137
151
  ) }
138
152
 
139
153
  <div className="row">
140
- { t('routes_order.step5.billing.comments') }
154
+ <label htmlFor={ id('comments') }>{ t('routes_order.step5.billing.comments') }</label>
141
155
 
142
- <textarea { ...refs('comments') }></textarea>
156
+ <textarea id={ id('comments') } { ...refs('comments') }></textarea>
143
157
  </div>
144
158
 
145
159
  <div className="row row-nomargin">
@@ -60,7 +60,11 @@ const Coupons = ({ current, step2, t, total, onApplied }: Props): (JSX.Element |
60
60
  <form onSubmit={ handleSubmit(onSubmit) }>
61
61
  <ContainerForm>
62
62
  <ContainerInput>
63
- <input type="text" placeholder={ t('routes_order.step5.coupon.code') } { ...register('code', Validate('required|min_length:4', t)) } />
63
+ { /* Edited: Claude - Date: 2026-08-19
64
+ aria-label rather than a <label for>, because this field has
65
+ no visible label to attach - the placeholder is the only
66
+ wording, and a placeholder disappears the moment you type. */ }
67
+ <input type="text" aria-label={ t('routes_order.step5.coupon.code') } placeholder={ t('routes_order.step5.coupon.code') } { ...register('code', Validate('required|min_length:4', t)) } />
64
68
 
65
69
  <Apply
66
70
  type="submit"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.31.3",
3
+ "version": "1.31.5",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"