@autobusal/common 1.36.1 → 1.37.0

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/Button/Button.tsx CHANGED
@@ -5,6 +5,13 @@ interface Props {
5
5
  type?: 'button' | 'submit'
6
6
  size?: 'medium' | 'small'
7
7
  loading?: boolean
8
+ /*
9
+ * Claude - 2026-08-30: a button that cannot be disabled is a real gap, not
10
+ * a style choice - the screen that hit it (bulk send with nothing selected)
11
+ * had to fall back to a bespoke <button> to express it, which is how that
12
+ * screen ended up not looking like the rest of the dashboard.
13
+ */
14
+ disabled?: boolean
8
15
  active?: boolean
9
16
  subtype?: 'secondary' | 'delete'
10
17
  text: string | JSX.Element
@@ -19,7 +26,7 @@ interface Props {
19
26
  value?: string
20
27
  }
21
28
 
22
- const Button = ({ type, size, loading, active, subtype, text, noMargin, maxWidth, onClick, name, value }: Props) => (
29
+ const Button = ({ type, size, loading, disabled, active, subtype, text, noMargin, maxWidth, onClick, name, value }: Props) => (
23
30
  <ButtonStyled
24
31
  $size={ size }
25
32
  type={ loading ? 'button' : type }
@@ -27,6 +34,12 @@ const Button = ({ type, size, loading, active, subtype, text, noMargin, maxWidth
27
34
  $subtype={ subtype }
28
35
  $margin={ noMargin ? false : true }
29
36
  $maxWidth={ maxWidth }
37
+ /*
38
+ * A loading button is disabled too: it already refuses to submit (the
39
+ * type swap above), and without this the pointer still says "click me"
40
+ * while a request is in flight.
41
+ */
42
+ disabled={ disabled || loading }
30
43
  onClick={ onClick }
31
44
  name={ name }
32
45
  value={ value }
package/Button/styles.ts CHANGED
@@ -70,4 +70,14 @@ export const ButtonStyled = styled.button<ButtonType>`
70
70
  &:hover {
71
71
  box-shadow: 0 4px 15px ${ props => props.theme.primary.normal };
72
72
  }
73
+ /*
74
+ * Claude - 2026-08-30: a disabled button has to LOOK unavailable, or the
75
+ * only feedback is a click that does nothing. Faded rather than restyled,
76
+ * so it is still recognisably the same button in the same place.
77
+ */
78
+ &:disabled {
79
+ opacity: 0.5;
80
+ cursor: not-allowed;
81
+ }
82
+
73
83
  `;
package/Drive/Drive.tsx CHANGED
@@ -26,7 +26,10 @@ const Drive = ({ locations, t }: Props): JSX.Element => {
26
26
  }
27
27
 
28
28
  // we get the coordinates for the roads to paint
29
- const geometryData = MapData?.features[0].geometry.coordinates;
29
+ // Claude - 2026-08-29 (audit A7-26): ONE feature now, not a GeoJSON
30
+ // envelope - and optional the whole way down, because "no line to draw" is
31
+ // an ordinary answer here rather than only a failed request
32
+ const geometryData = MapData?.geometry?.coordinates;
30
33
 
31
34
  // we paint the drive lines
32
35
  const lines = geometryData?.map((item, index) => {
package/Drive/services.ts CHANGED
@@ -1,32 +1,41 @@
1
1
  import { useQuery, UseQueryResult } from '@tanstack/react-query';
2
2
  import { apiClient } from '@autobusal/providers';
3
- import { GeoJsonData } from './types';
3
+ import { DriveLine } from './types';
4
4
 
5
- export const useGetDriveData = (coordinates: number[][]): UseQueryResult<GeoJsonData> => (
5
+ export const useGetDriveData = (coordinates: number[][]): UseQueryResult<DriveLine | null> => (
6
6
  useQuery({
7
7
  // Edited: Ferjolt Ozuni - Date: 2026-08-01
8
8
  // The key was a bare ['drive-data'] with the coordinates absent, so
9
9
  // every route on the site shared one cached itinerary: open one route
10
10
  // page and then another, and the second drew the first one's road line.
11
11
  queryKey: ['drive-data', { coordinates }],
12
- // With no key configured (or a route with fewer than two placed stops)
13
- // this only buys a 401 and a console error on every route page view.
14
- // Skipping it leaves the map showing stops and no road, which is what
15
- // it already did - just quietly.
16
- enabled: Boolean(import.meta.env.VITE_OPEN_ROUTE_SERVICE_KEY) && coordinates.length > 1,
12
+ /*
13
+ * Claude - 2026-08-29 (audit A7-26, low)
14
+ *
15
+ * ASKED THROUGH OUR OWN API, not from the browser. OpenRouteService
16
+ * answers a browser with no Access-Control-Allow-Origin, so this request
17
+ * never actually left - every route page logged a CORS failure and drew
18
+ * its stops with no road between them. And it carried a metered
19
+ * third-party API key, in the bundle, on a public page.
20
+ *
21
+ * obtapi has asked ORS from the server since August for the DISTANCE on
22
+ * this very geometry (Libraries\Routes\Distance, whose own header
23
+ * explains why); this is the same call with the line kept. Cached
24
+ * server-side per geometry, so one answer serves every visitor to a pair
25
+ * instead of each of them paying for their own.
26
+ *
27
+ * The coordinates go over as [lat, lng] pairs - the order this codebase
28
+ * writes a coordinate in everywhere except the ORS request itself, which
29
+ * the server now flips.
30
+ */
31
+ enabled: coordinates.length > 1,
17
32
  queryFn: async () => (
18
33
  await apiClient
19
- .post('https://api.openrouteservice.org/v2/directions/driving-car/geojson', {
20
- coordinates
21
- }, {
22
- headers: {
23
- Authorization: import.meta.env.VITE_OPEN_ROUTE_SERVICE_KEY
24
- },
25
- withCredentials: false,
26
- withXSRFToken: false
34
+ .post('/api/routes/directions', {
35
+ points: coordinates.map(point => [point[1], point[0]])
27
36
  })
28
37
  .then(response => (
29
- response.data
38
+ response.data?.line ?? null
30
39
  ))
31
40
  )
32
41
  })
package/Drive/types.ts CHANGED
@@ -1,11 +1,15 @@
1
- export interface GeoJsonData {
2
- features: FeaturesData[]
3
- }
4
-
5
- interface FeaturesData {
1
+ /**
2
+ * Claude - 2026-08-29 (audit A7-26): the drive line is now ONE feature, not
3
+ * a GeoJSON envelope - obtapi's /routes/directions returns the LineString it
4
+ * picked out of the provider's answer, or null when there is no line to draw
5
+ * (no provider configured, fewer than two placed stops, or the provider
6
+ * unreachable). That also removes the `features[0]` the consumer subscripted,
7
+ * which was one empty collection away from throwing.
8
+ */
9
+ export interface DriveLine {
6
10
  geometry: GeometryData
7
11
  }
8
12
 
9
13
  interface GeometryData {
10
14
  coordinates: string[][]
11
- }
15
+ }
package/Seats/Preview.tsx CHANGED
@@ -18,6 +18,21 @@ const Preview = ({ bus, occupied, picked, selected, t, onSelect, onClose }: Prop
18
18
  const items = bus.seats.map((item, index) => {
19
19
  const isOccupied = occupied.includes(item.no) || picked.includes(item.no);
20
20
 
21
+ /*
22
+ * Claude - 2026-08-29 (audit A3-08c, low)
23
+ *
24
+ * A TAKEN SEAT SAYS SO, rather than only being red. It stayed an enabled
25
+ * button whose click was silently swallowed, and colour was the single
26
+ * thing distinguishing it - so a colour-blind buyer, a screen-reader
27
+ * user, or anybody on a washed-out phone screen in daylight could only
28
+ * tell a sold seat from a free one by clicking it and getting nothing.
29
+ *
30
+ * `disabled` rather than a quiet no-op, because that is what stops a
31
+ * pointer, a keyboard and assistive technology at once; onSelect keeps
32
+ * its own guard regardless. The title carries the reason for a mouse and
33
+ * aria-label carries it for a reader, since the visible content is a bare
34
+ * seat number either way.
35
+ */
21
36
  return (
22
37
  <ButtonOccupy
23
38
  key={ index }
@@ -28,6 +43,12 @@ const Preview = ({ bus, occupied, picked, selected, t, onSelect, onClose }: Prop
28
43
  $height={ item.height }
29
44
  $selected={ selected === item.no }
30
45
  $occupied={ isOccupied }
46
+ disabled={ isOccupied }
47
+ aria-pressed={ selected === item.no }
48
+ title={ isOccupied ? t('seats.taken', { ns: 'common', number: item.no }) : undefined }
49
+ aria-label={ isOccupied
50
+ ? t('seats.taken', { ns: 'common', number: item.no })
51
+ : t('seats.free', { ns: 'common', number: item.no }) }
31
52
  onClick={ () => onSelect(item.no, isOccupied) }
32
53
  >
33
54
  <MdEventSeat /> { item.no }
package/Seats/styles.ts CHANGED
@@ -89,12 +89,25 @@ export const ButtonOccupy = styled.button<{
89
89
  background: ${ props => props.theme.primary.normal };
90
90
  ` }
91
91
 
92
+ /*
93
+ * Claude - 2026-08-29 (audit A3-08c, low): a taken seat is disabled as well
94
+ * as red, so it reads as unavailable to somebody who cannot see the colour.
95
+ * The strike-through and the cursor are the non-colour half of that signal;
96
+ * losing the hover response is the other half - a control that answers
97
+ * nothing should not look like it is about to.
98
+ */
92
99
  ${ props => props.$occupied && css`
93
100
  color: #FFFFFF;
94
101
  background: ${ props => props.theme.font.error };
102
+ text-decoration: line-through;
95
103
  ` }
96
104
 
97
- &:hover {
105
+ &:disabled {
106
+ cursor: not-allowed;
107
+ opacity: .75;
108
+ }
109
+
110
+ &:hover:not(:disabled) {
98
111
  opacity: .8;
99
112
  }
100
113
  `;
@@ -1,6 +1,6 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import { AiOutlineCheck, AiOutlineCloudDownload } from 'react-icons/ai';
3
- import { BiEditAlt, BiTransfer, BiTrash } from 'react-icons/bi';
3
+ import { BiEditAlt, BiEnvelope, BiMessageDetail, BiTransfer, BiTrash } from 'react-icons/bi';
4
4
  import { FaCity, FaTrash } from 'react-icons/fa';
5
5
  import { GiMoneyStack } from 'react-icons/gi';
6
6
  import { IoDocumentLockOutline } from 'react-icons/io5';
@@ -22,6 +22,25 @@ const Icon = ({ type, t }: Props): (JSX.Element | null) => {
22
22
  case 'delete':
23
23
  return <BiTrash title={ t('table.actions.delete', { ns: 'common' }) } />;
24
24
 
25
+ /*
26
+ * Claude - 2026-08-30: two verbs the operator-invitation list needs.
27
+ *
28
+ * Added to the shared vocabulary rather than drawn as bespoke buttons on
29
+ * that one screen: an action that looks like an action everywhere else is
30
+ * the entire point of this component, and the screen that prompted this
31
+ * had hand-rolled its own row controls precisely because these were
32
+ * missing.
33
+ *
34
+ * 'compose' is writing the text that will be sent; 'send' is sending it.
35
+ * Two steps, two icons - a single 'mail' for both would make the
36
+ * destructive-ish one (it really posts) look like the harmless one.
37
+ */
38
+ case 'compose':
39
+ return <BiMessageDetail title={ t('table.actions.custom.compose', { ns: 'common' }) } />;
40
+
41
+ case 'send':
42
+ return <BiEnvelope title={ t('table.actions.custom.send', { ns: 'common' }) } />;
43
+
25
44
  case 'login_as':
26
45
  return <RiLoginCircleLine title={ t('table.actions.custom.login_as', { ns: 'common' }) } />;
27
46
 
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.36.1",
3
+ "version": "1.37.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
- "main": "index.ts"
6
+ "main": "index.ts",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ }
7
10
  }
package/CHANGELOG.md DELETED
@@ -1,771 +0,0 @@
1
- # Changelog
2
-
3
- ## 1.36.0 (2026-08-29)
4
-
5
- - Editors offer Save & Close alongside Save & Stay, so correcting a record no longer bounces you back to its list every time (opt-in per page, editing only). The pair is driven from here (Viewer + Actions).
6
-
7
- ## 1.35.0 (2026-08-29)
8
-
9
- - RouteFeature's compact chip draws a real tooltip on hover AND on focus, so a tap reveals the amenity name; `title` alone was invisible on touch and slow on desktop.
10
- - Calendar treats an unparseable defaultValue as nothing chosen, and anchors an empty picker on its minDate - so a floored picker (the return leg) opens on the month being travelled rather than on today or on January.
11
-
12
- ## 1.34.1 (2026-08-29)
13
-
14
- - Meta's stale-tag cleanup can no longer remove a tag React owns: it now identifies leftovers by having existed before React rendered, not by comparing values (Sentry BUSMAGUS-7 - on a prerendered page the leftover and the live tag carry the same value, so the old comparison deleted React's own node and the next navigation crashed on removeChild).
15
-
16
- ## 1.34.0 (2026-08-29)
17
-
18
- - Calendar renders one month per page - the neighbouring months' days are blank padding now, so a grid no longer shows two different 1sts and 31sts.
19
- - Calendar takes `maxYears`, raising a single picker's ceiling above what its type allows (booking pickers stay at one year); Viewer forwards it on 'picker' rows.
20
- - New SearchableSelect: a <select> you can type into - accent-insensitive matching, group headings, inert disabled options, written for the operator's stop picker.
21
- - BackWithTitle takes `actions`, rendered as pill links at the far end of the title line.
22
- - Table row actions for 'cities' and 'stations'.
23
-
24
- ## 1.33.14 (2026-08-26)
25
-
26
- - ChooseSeat: seat-selection add-on mode - `optional` + `fee` props; unpicked state shows the free-auto note, a made choice gets a back-to-automatic control. Default behaviour (required free pick) unchanged.
27
-
28
- ## 1.33.13
29
-
30
- ### Fixed
31
-
32
- - **`RouteItem` no longer advertises "From 0.00"**: zero-fare rows are unpriced legs search never sells, so they no longer count toward the cheapest-of figure. (Audit RT-11b.)
33
-
34
- ## 1.33.12
35
-
36
- ### Added
37
-
38
- - **`DefaultLocaleGate`** - the mirror of `LocaleGate` for the UNPREFIXED public branch: a visitor whose stored language is not the brand default is redirected from `/help` to `/{lang}/help`, so the page shown always matches the address (and its canonical). Crawlers carry no localStorage and keep seeing the canonical default-language page. (Audit decision CMS-07c.)
39
-
40
- ## 1.33.11
41
-
42
- ### Fixed
43
-
44
- - **The "Add Funds" link in the Agents and Visitors lists 404'd.** It pointed at `/admin/funds/manage/:id`, a route that has never existed; the funds form lives at `/account/funds/manage/:id` (admin + financer). (Platform audit ADM-06g.)
45
-
46
- ## 1.33.9
47
-
48
- ### Fixed
49
-
50
- - **Every field built through `Viewer` was unnamed.** The label was a `<span>` beside an input with no id, so each one announced as a bare "edit text" and clicking a label did nothing - across every admin and account form in the app, not one screen. `Item` now mints a `useId`-prefixed id for single-control rows and the label is a real `<label htmlFor>`. Additive: no caller changes, and the styled rule set was empty either way.
51
-
52
- ### Added
53
-
54
- - `Table` and `Viewer` take an optional `confirm`, so a destructive action can ask a question specific to what it destroys rather than the generic one. Existing callers keep the generic question.
55
-
56
- ## 1.33.8
57
-
58
- ### Added
59
-
60
- - **`Table` headers can sort.** A header cell now takes an optional sort key and direction and renders as a control when it does, so a table backed by a paginated, server-sorted endpoint can drive it from the header rather than a separate control. Tables that pass no sort key are untouched and render exactly as before.
61
-
62
- ## 1.33.7
63
-
64
- ### Changed
65
-
66
- - **`Required` renders a real `<label>`, and takes an optional `htmlFor`.** It was a `styled.span` sitting beside the field it named, which is a label to a sighted reader and nothing at all to a screen reader - every field it introduced announced as a bare "edit text". Nothing in the stylesheets selects `label` and a flex item computes identically either way, so this is semantics only.
67
- - **`Password` and `Calendar` take an optional `id`.** A caller cannot associate a visible label with a field it has no way to name, so the shared inputs had to be able to accept one before the forms using them could be fixed.
68
- - `AiAssist/AiReviewModal` runs its three HTML panes through `sanitizeHtml`. Everything the model returns is HTML rendered into an admin's browser, and every other sink in the codebase sanitises; this one did not.
69
-
70
- ## 1.33.6
71
-
72
- ### Added
73
-
74
- - **New `Map/Tiles` component - the one place the basemap comes from.** The same `TileLayer` line was copy-pasted into `Drive.tsx`, `@autobusal/map`'s `Display.tsx` and magus's own `RouteMap.tsx`, with a fourth copy in the mobile app, all hardcoding `tile.openstreetmap.org`. OSM's Tile Usage Policy does not cover commercial or heavy application use, and those servers throttle or block by referer and volume without notice - so the exposure was never the bill, it was that reacting to a block meant editing three repos, publishing to npm, redeploying both frontends and shipping a mobile store release.
75
-
76
- The URL now comes from `/settings/get` (`map.tile_url`) rather than the build, so all four clients switch from one server env var. `null` - the state until a provider is chosen - means the built-in OpenStreetMap default, so this changes nothing for anyone on upgrade. Attribution is rendered by the component rather than travelling with the URL, since ODbL requires it whoever serves the tiles.
77
-
78
- ### Changed
79
-
80
- - `Drive.tsx` renders `<Tiles />` instead of its own `TileLayer`.
81
-
82
- ## 1.33.4
83
-
84
- ### Added
85
-
86
- - **New `LocalizedFields` component** - a generic per-language field editor generalising SeoOverride (fixed to title/description) and Faqs/Translations (fixed to question/answer) into one taking an arbitrary field list. A section with just one translatable field (a category name, a menu label) no longer needs its own bespoke ~120-line copy of the same editor to support more than English+Albanian.
87
-
88
- ## 1.33.3
89
-
90
- ### Added
91
-
92
- - **`RouteItem` shows a "from" price** next to the route name/code - the lowest fare across the route's own `prices`, using the row's own pre-formatted `adult_display` (same currency/decimal convention as everywhere else). Both `browse` and `view` modes get it; a route with no prices at all shows nothing, same as it always could.
93
-
94
- ## 1.33.2
95
-
96
- ### Added
97
-
98
- - **`Meta` now defaults `og:image`/`twitter:image` to the label's own homepage hero photo** (`{settings.url}/home/background-light.jpg`) when a page doesn't pass its own `image`. Most of the ~136 call sites never had a share-preview image at all, so a link pasted into a chat assistant or social post previewed with nothing. A page with something more specific (an operator's logo, a blog post's own photo) still passes its own `image` and wins - this only fills the gap.
99
-
100
- ## 1.33.1
101
-
102
- ### Added
103
-
104
- - **`Table` accepts an optional `onReorder(draggedId, targetId)` prop.** When set, every row becomes an HTML5 drag source/drop target (dims to 40% opacity while being dragged) and a completed drag reports the dragged row's id and the row it was dropped on - the caller decides what "reorder" means for its own data. Omitted by every existing table, so this is purely additive; first consumer is `@autobusal/admin-menu` 1.1.2.
105
-
106
- ## 1.33.0
107
-
108
- ### Fixed
109
-
110
- - **`Viewer`'s `type: 'component'` rows never actually rendered, anywhere.** `Data.tsx` returned `null` for any item whose `name` was `undefined` before the switch statement that handled `'component'` ever ran - and a component row never HAS a name, it isn't a registered form field. Every SectionHeading divider, every `AiFieldAssist` Generate/Improve/Translate trigger, and every description paragraph moved inside a form's own box (SMS/Flex, this cycle) had silently never rendered, in any tab that used one, despite compiling clean and sitting right there in the bundle. Moved the `'component'` case to an early return above the name guard instead of weakening the guard's condition, which would have broken TypeScript's narrowing of `item.name` to `string` for every other case below it.
111
- - **AI trigger buttons and the review modal rendered raw i18n keys** (`ai_assist.trigger.generate`) instead of translated text. `ai_assist.*` lives in the `common` namespace, but the `t` passed into `AiFieldAssist`/`AiReviewModal` defaults to whatever namespace the calling page's own `useTranslation()` uses (`normal`/`whitelabel`) - every call now passes `{ ns: 'common' }`.
112
- - **`AiReviewModal` showed a generic "Request failed with status code 422"** instead of the real provider error on a failed generate/translate/improve. Removed its local error state/display entirely - `apiClient`'s existing global response interceptor already toasts `error.response.data.message` on any 422, the same mechanism every other form in the app already relies on; a local `onError` reading react-query's plain `Error.message` could only ever see Axios's generic text, never the backend's.
113
-
114
- ## 1.32.0
115
-
116
- ### Added
117
-
118
- - **Drag & drop on `File`**, the shared upload control used everywhere in the app - drop a file directly onto it instead of always going through the native picker. The control's own footprint (a single 38px-tall inline button) is unchanged everywhere it is already used; only a border/background highlight appears while dragging. A dropped file is handed to the same hidden `<input>` react-hook-form already registered, via a real dispatched `change` event, so every existing caller's validation/onChange keeps working with no changes on their end.
119
-
120
- ### Fixed
121
-
122
- - **A checkbox row with a long notice no longer stretches sideways.** `.row-checkbox`'s label+input are laid out `flex-direction: row-reverse`, and the row's own notice/error text (a plain div) was sharing that same row - a short notice fit fine, but a longer one (two sentences, easily) stretched the row's WIDTH to fit on one line, visibly pushing the checkbox by however long the text happened to be. Every div child of a checkbox row now gets `flex-basis: 100%`, dropping straight onto its own full-width line; the label and input are unaffected.
123
-
124
- ## 1.31.0
125
-
126
- ### Added
127
-
128
- - **A `row-component` field spans the full row width**, same as `row-textarea-html` already did - what lets a Viewer form's own section headings (`SectionHeading`, admin-label's Content/styles.ts) sit as a full-width divider instead of sharing a row with whatever field lands beside them.
129
-
130
- ## 1.30.0
131
-
132
- ### Changed
133
-
134
- - **`Viewer`'s field grid stays at two columns on every screen width.** It
135
- used to widen to three columns at >=1300px - fine for a form of
136
- independent, unrelated fields, but a form built for a two-column rhythm
137
- (one field, a divider row, a related pair, a divider row, ...) reflows
138
- into three and starts mixing fields from different logical groups onto
139
- the same row, with nothing to say which group a given row belongs to.
140
- Affects every Viewer-based form in the app, not just admin-label's.
141
-
142
- ## 1.29.0
143
-
144
- ### Added
145
-
146
- - **`AiFieldAssist` + the AI review modal.** The one AI-review surface every
147
- content admin form shares - Generate/Improve/Translate triggers that open
148
- a modal where a draft is always reviewed (and can be hand-edited or
149
- revised on a follow-up instruction) before an explicit Insert writes it
150
- into the calling field. Renders nothing at all when no AI provider is
151
- enabled for the label - the visibility gate the AI plan calls for, with
152
- no separate flag to keep in sync.
153
- - **`Viewer`'s `'component'` field type accepts a function** of the form's
154
- own `setValue`, not just plain JSX - the only way a component embedded in
155
- the field list (AiFieldAssist writing a draft into a SIBLING field like
156
- content_en) can reach state Viewer has always kept internal. Existing
157
- callers passing plain JSX are unaffected.
158
-
159
- ## 1.28.0
160
-
161
- ### Added
162
-
163
- - **`Row` takes an optional `actions`**, overriding the table's list for that
164
- row. The table-level prop is one array for every row, which cannot express
165
- an action only some rows have earned - a ticket download belongs to a paid
166
- order and to no other. `Rows` falls back to the table's list, so every
167
- existing caller is unaffected.
168
- - **`print` and `invoice` row-action icons.** Distinct from the existing
169
- generic `download`.
170
-
171
-
172
- ## 1.27.5
173
-
174
- ### Added
175
-
176
- - **`placeholder` on a `select`** — a leading empty option, for "nothing
177
- chosen".
178
-
179
- A select whose value matches no option silently displays the first one and
180
- the browser submits it as though it had been picked. That is exactly how
181
- every profile whose owner never touched the country field was saved as
182
- **Afghanistan**: it sorts first, and nothing had ever been selected.
183
- Changing the default from `0` to `''` did not help on its own — neither
184
- value exists as an option, so the browser fell back either way.
185
-
186
- Rendered only when placeholder text is supplied, so a select with a real
187
- default (sex, status, a type) does not grow an empty row it never wanted.
188
-
189
- ## 1.27.4
190
-
191
- ### Fixed
192
-
193
- - **The calendar allowed a different set of days depending on the visitor's
194
- timezone.** Day cells are built with `createDate`, which is `Date.UTC(...)`;
195
- the selectable bounds were built with `new Date(y, m, d)` — *local*
196
- midnight. Those are not the same instant, so the boundary day fell on
197
- either side of the limit depending on where the visitor was sitting.
198
-
199
- The consequence on the money path: **same-day departures were not
200
- selectable west of Greenwich.** A visitor in New York or Los Angeles could
201
- not pick today, because local midnight there is *after* the cell's UTC
202
- midnight, so today's cell tested as below the minimum. Same-day selling was
203
- deliberately enabled — this had been quietly undoing it for those visitors,
204
- and it is invisible from Europe. Both sides are UTC now, so the comparison
205
- is exact and identical everywhere.
206
-
207
- Found while chasing an apparent off-by-one in the birth-date picker, which
208
- turned out to be this skew wearing a different hat.
209
-
210
- ## 1.27.3
211
-
212
- ### Fixed
213
-
214
- - **The birth-date picker offered today, which the API refuses.** obtapi
215
- validates with `before:today`, so the calendar was letting you choose an
216
- answer and then calling it wrong. It now stops at yesterday.
217
-
218
- The year steps back too when yesterday fell in the previous one: the
219
- maximum is built from `today.getFullYear() + limits.max.year`, so on 1
220
- January a month/day of 31 December would otherwise have resolved to the
221
- coming 31 December rather than the one just gone. Checked against 1
222
- January, 1 March and a leap year.
223
-
224
- ## 1.27.2
225
-
226
- ### Fixed
227
-
228
- - **A birth date no longer defaults to today.** `Calendar` pre-fills with
229
- today when given no value, which is right for every other use of it — a
230
- departure, a report range, a broadcast day — and wrong for exactly one:
231
- nobody is born today. On the profile-completion form it meant anybody who
232
- filled in the fields that *looked* empty saved a date of birth of today,
233
- silently, and that is the field a passenger's age is judged by when they
234
- book. Reproduced end to end: the row saved `dob = <today>`.
235
-
236
- `type="dob"` now starts empty so the field reads as unanswered. The picker
237
- still opens on the current month — `createDate('')` is an Invalid Date and
238
- the grid built from it renders as nothing — but that anchor is display
239
- only; what the form submits stays empty until a day is clicked, and
240
- obtapi's new `required|date_format:d/m/Y|before:today` says so if it is
241
- left that way.
242
-
243
-
244
- ## 1.27.1
245
-
246
- ### Removed
247
-
248
- - **Everything 1.27.0 added for multi-TLD** — `localeHref`, the `LocaleDomain`
249
- type, `Meta`'s domain-resolved canonical and hreflang, and
250
- `ChangeLanguage`'s cross-domain navigation. `Meta` is back to resolving both
251
- from `window.location.origin` plus the `/{locale}/` prefix.
252
-
253
- The product decision changed after the code landed: ccTLDs will **301 to
254
- `.com/{locale}/`** rather than be real per-country sites, so none of this
255
- had a caller and none of it ever would. A ccTLD's geo signal multiplies
256
- content that already exists, and a new ccTLD starts at zero authority while
257
- a subdirectory inherits the domain's on day one.
258
-
259
- **Do not downgrade to 1.26.1 to get this** — 1.27.0 is published, so
260
- `@latest` would reintroduce it. This version is 1.26.1's behaviour with a
261
- higher number.
262
-
263
- ## 1.26.1
264
-
265
- ### Fixed
266
-
267
- - **`Table` drew an empty "Options" column when a table had only
268
- top-of-table controls.** `create`, `search` and `extra` all live *above* the
269
- table — `Actions/Get` already returns null for each — but `Header` and
270
- `Rows` decided whether to draw the options column from `actions.length > 0`,
271
- which counted them. A table using the component purely for sorting, search
272
- and paging therefore got an "Options" heading with an empty cell under every
273
- row.
274
-
275
- Invisible on the admin screens, where every table has row actions anyway;
276
- visible the moment the public bus-company directory used it. The row/top
277
- split is one shared list now (`Table/rowActions.ts`) rather than a condition
278
- repeated in three files that could drift.
279
-
280
- ## 1.26.0
281
-
282
- ### Added
283
-
284
- - **`HandoffBanner`** — "You are managing *X* as its administrator", shown on a
285
- brand's own domain for the length of a brand handoff, with a **Finish**
286
- button that ends the session. Rendered by both apps' private layouts; renders
287
- nothing at all unless `/bff/session` reports a handoff, so an ordinary
288
- session costs one request. See `magus/BRAND_HANDOFF_PLAN.md`.
289
-
290
- Not the same component as magus's `ImpersonationBanner` and not driven by the
291
- same signal: that one reads `localStorage['impersonating']`, which page
292
- JavaScript sets and can therefore be wrong. This reads the BFF session, which
293
- is the only thing that actually knows. There is also nothing on this origin to
294
- *revert* to — the admin's own session is alive and untouched on a different
295
- origin — so the action is an explicit end, not a revert.
296
-
297
- - **`handoff` action on `Table`** — its own icon and label rather than borrowing
298
- `login_as`, for the same reason `reply` is not `approve`: `login_as` changes
299
- who you are *within* a brand, a handoff leaves for another brand's domain in a
300
- new tab.
301
-
302
- ### Notes
303
-
304
- - **There is deliberately no `pagehide`/`sendBeacon` revoke.** One was built, to
305
- shorten the window when somebody closes the tab, and testing removed it:
306
- `pagehide` with `persisted === false` also fires on a plain **page refresh**,
307
- so F5 revoked the token and dumped the admin on the logged-out screen.
308
- Measured — an identical reload driven by curl, with no beacon in play, keeps
309
- the session. No browser API distinguishes closing from reloading at that
310
- moment, so what remains is two mechanisms that both work (the Finish button
311
- and the two-hour token expiry) rather than three where one misfires.
312
-
313
- - The banner puts **Finish next to the message** rather than at the far edge.
314
- `space-between` reads better but the whitelabel admin's content column is a
315
- fixed 1491px that overflows the viewport at ordinary window sizes, which put
316
- the button off-screen at x=1856 — the one control that reliably ends the
317
- session cannot be the one you have to scroll sideways to find.
318
-
319
- ## 1.25.1
320
-
321
- **The SEO override wears the site's own form clothes.**
322
-
323
- `SeoOverride` declared its own `Field`/`Label`/`Input`/`Textarea` and restated the global field styling with *different* values — transparent instead of `inputs.background`, a primary-coloured border, its own font size — then sat on the bare page rather than a card. On a country or city screen that made the two SEO fields the only ones on screen not matching the form directly beneath them, which is the opposite of what its own comment claimed.
324
-
325
- It uses `.box` and `.row` now, the site's own card and field classes, with plain inputs so `GlobalStyles` reaches them exactly as it reaches every `Viewer` row. Being controlled is the only thing that genuinely differs from a Viewer row, and that is a state concern, not a visual one. The textarea keeps a single rule, for height: `GlobalStyles` gives every text input 38px, right for one line and useless for a description.
326
-
327
- Reaches the label SEO tab too, which had the same problem. Checked it does not land inside another card — `admin-label`'s `Options` is the tab strip, not a wrapper around the tab bodies.
328
-
329
- ## 1.25.0
330
-
331
- **New `setDepartureZone(zone)` — the date picker floors on today WHERE THE COACH LEAVES.**
332
-
333
- `setServerToday` was already an improvement on the buyer's device clock, but it is still the wrong clock: it is *our* date, in Tirana, and a coach leaving Bari has nothing to do with it. Only the departure city can say whether the 4th is still today for that journey.
334
-
335
- Falls back to `serverToday`, then to the device, so the bare search form (no journey chosen yet) behaves exactly as before. An unresolvable zone returns null and falls back too — a picker that refuses to open because a timezone lookup failed would be far worse than one flooring a day out.
336
-
337
- ## 1.24.0
338
-
339
- **`Policy` is now `Information`.**
340
-
341
- - `Policy` → `Information`, `useGetPolicy` → `useGetInformation`, and the endpoint behind it moves from `/api/routes/policy` to `/api/routes/information`. **Breaking**: update the import.
342
- - `RouteItem`'s `ReadPolicy` → `ReadInformation`, reading `route_item.information`.
343
- - Translation keys: `policy.title` → `information.title`, `route_item.policy` → `route_item.information`.
344
-
345
- The rename follows the Flexible Ticket model, where what a ticket permits comes from the add-on and the operator's own windows — not from this box.
346
-
347
- All notable changes to `@autobusal/common` are documented here. This project follows
348
- [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
349
-
350
- > Note: 1.10.0 through 1.15.1 were published without changelog entries. The
351
- > gap is left as-is rather than reconstructed after the fact.
352
-
353
- ## 1.23.0
354
-
355
- An unbookable date is inert, not merely faded.
356
-
357
- It was dimmed and otherwise fully interactive: the pointer cursor and the
358
- hover highlight still invited a click, and the click was answered with a
359
- browser alert saying the date was invalid. The calendar offered a day, let
360
- somebody reach for it, then told them off for taking the offer.
361
-
362
- `pointer-events: none` removes it from the conversation entirely — no cursor
363
- change, no hover, no click to refuse — alongside the `aria-disabled` and
364
- `tabIndex=-1` already on the element. Unreachable by mouse, keyboard and
365
- screen reader alike, and the alert is gone with it.
366
-
367
- ## 1.22.0
368
-
369
- The booking calendar floors on the SERVER's date, not the browser's.
370
-
371
- `new Date()` is the buyer's device clock, which says nothing about where they
372
- are travelling. Somebody in Shanghai at 06:00 on the 5th, looking at an
373
- Italian coach that does not leave until the evening of the 4th, had that day
374
- greyed out — while the server would have sold them the seat quite happily. The
375
- calendar was refusing a sale the API accepted.
376
-
377
- It also makes the picker agree with the date strip beside it, which has always
378
- been built server-side; the two used to disagree about which day came first.
379
-
380
- A date of birth keeps the local clock, because that one genuinely is about the
381
- person in front of the screen.
382
-
383
- ## 1.21.0
384
-
385
- `reply` joins the table actions with its own icon and label. The operator
386
- review page was borrowing `approve`, so the button on a review about them read
387
- "Approve" — which is the one thing they cannot do to it.
388
-
389
- ## 1.20.0
390
-
391
- `Rating` — stars, average and count, or nothing at all. Rendering nothing is
392
- the feature: obtapi withholds a score until there are enough published reviews
393
- to say something honest, so an absent rating has to read as "not saying yet"
394
- rather than as a zero. Anything filling the gap with empty stars would turn a
395
- new operator into a badly-rated one. Half stars are drawn, because rounding 3.5
396
- down loses the difference between middling and good — precisely the range where
397
- a reader is still deciding.
398
-
399
- ## 1.19.0
400
-
401
- `extra` joins `create` and `search` as a top-of-table slot rather than a
402
- per-row action. Any table opting into the extra slot also rendered one empty,
403
- iconless, still-clickable button in every row's options cell — Icon has no
404
- case for `extra`, so it drew nothing while taking the click. The refund queue
405
- has been doing this all along; the new review queue would have too.
406
-
407
- ## [1.18.0] - 2026-08-02
408
-
409
- ### Added
410
-
411
- - **`SeoOverride`** - a per-language editor for the SEO title/description
412
- overrides, shared by the label, country and city forms. One language is
413
- shown at a time (a brand writes copy for one or two, and thirteen sets of
414
- empty inputs invite nobody to fill any of them in) and the languages that
415
- already have copy are marked, so it is obvious which are done. Controlled
416
- and deliberately form-less: those pages submit every field in one request,
417
- so an editor with its own save would either post a partial update or leave
418
- two save buttons meaning different things on one screen.
419
-
420
- ## [1.17.0] - 2026-08-02
421
-
422
- ### Added
423
-
424
- - **Locale-prefixed URLs.** `localiseRoutes()` mirrors an absolute-path route
425
- tree under `/:locale`, and `LocaleGate` guards that branch - rejecting a
426
- prefix that is not one of the brand's languages, redirecting the default
427
- language back to its unprefixed home so only one URL is canonical, and
428
- switching i18n to whatever the URL asks for. `splitLocale`/`withLocale`/
429
- `localeFromPath` are the shared, router-free definition of a localised
430
- path, so the crawler and the sitemap agree with the components.
431
- - **hreflang alternates on every page.** Emitted from `Meta`, which already
432
- sits on ~136 call sites, so every real page gains them at once and a new
433
- page cannot forget them. `x-default` points at the unprefixed URL.
434
- Suppressed for noindex pages and whenever a caller passes an explicit
435
- canonical, which the path-derived alternates would contradict.
436
-
437
- ### Changed
438
-
439
- - **`ChangeLanguage` navigates** instead of only swapping the rendered text -
440
- behind an explicit `localised` prop, so the account and admin areas (which
441
- are deliberately not locale-prefixed) keep switching in place rather than
442
- navigating to a 404.
443
- - **The canonical drops a trailing slash.** Apache serves a prerendered page
444
- from a directory and 301s `/contact` to `/contact/`, so the same page used
445
- to advertise whichever form the visitor happened to arrive on. Harmless
446
- when it was the only path-derived tag; not harmless now that the alternates
447
- come from the same value, because one page would announce two URL sets.
448
-
449
- ## [1.16.0] - 2026-08-01
450
-
451
- ### Added
452
-
453
- - **The date field can tell its form when the day changes.** `ViewData.onChange`
454
- was already honoured by `select` and `checkbox`, but not by `date`/`dob`/
455
- `picker`, so a form whose other fields depend on the chosen day had no way
456
- to observe it short of not using `Viewer` at all. `Calendar` now takes an
457
- optional `onChange(DD/MM/YYYY)` and `Viewer` passes `item.onChange` through
458
- to it. Purely additive - existing callers are unaffected.
459
-
460
- ## [1.15.6] - 2026-08-01
461
-
462
- ### Changed
463
-
464
- - **An admin sees the real site while maintenance is on.** Deliberately
465
- admin only - an operator or agent signing in still gets the holding page,
466
- because the site is not open to them either. Presentation, not
467
- protection: obtapi still decides what any account may do.
468
- - `Calendar` takes an optional `minDate`, which can only RAISE the floor
469
- the type already sets. Used by the return leg so it cannot start before
470
- the outbound date.
471
-
472
- ### Fixed
473
-
474
- - `Required` rendered a fragment, so in the column-flex form rows the label
475
- and its asterisk became two flex items and the asterisk dropped onto its
476
- own line.
477
-
478
- ## [1.15.5] - 2026-08-01
479
-
480
- ### Added
481
-
482
- - `Required` - marks a form label as mandatory. The asterisk is
483
- `aria-hidden`, since a screen reader announcing "star" tells nobody
484
- anything; the input's own validation carries the semantics.
485
-
486
- ## [1.15.4] - 2026-08-01
487
-
488
- ### Fixed
489
-
490
- - **The language dropdown opened upward over the navbar.** It positioned
491
- itself with `bottom: calc((-1 * languages * 27px) - 50px - 8px)`, guessing
492
- where its own bottom edge should land by multiplying an assumed 27px row
493
- height by the language count. The rows are not 27px, so the error grew
494
- with every language a brand enabled - at thirteen it was far enough out to
495
- open over the page instead of below the trigger. Now `top: 100%`, which
496
- needs no arithmetic and cannot drift, plus a height cap so a brand with
497
- twenty languages does not run off the screen.
498
- - **`RouteItem` showed the wrong arrival country.** The city read
499
- `locations[lastLocation]` but the country read `locations[1]`. Identical
500
- on a two-stop route, wrong on anything longer: Tirana-Veria-Thessaloniki
501
- rendered "Thessaloniki (alb)", because `locations[1]` is Elbasan.
502
- - Country codes render upper case here too, matching the search results.
503
-
504
- ## [1.15.3] - 2026-08-01
505
-
506
- ### Changed
507
-
508
- - **Language switcher shows just the language name**, left-aligned, with
509
- the panel title left-aligned too. The "Change to" prefix on every row
510
- said nothing the panel's own title and the flag beside it were not
511
- already saying. One component serves both the public header and the
512
- panel sidebar, so both are fixed.
513
- - **The route map fits the trip** instead of sitting at a fixed zoom 6
514
- centred on the first stop. Every route drew the same view of the Balkans
515
- and none of them filled it, so following a route meant panning.
516
-
517
- ### Fixed
518
-
519
- - **`getCoordinates` sent every interior stop twice** - a four-stop route
520
- went out as A,B,B,C,C,D,D,D, with the last point repeated against
521
- itself. Directions APIs reject consecutive identical coordinates, so the
522
- request failed and the map fell back to bare markers with no road line.
523
- - **`useGetDriveData` cached on a bare `['drive-data']` key**, with the
524
- coordinates absent - so every route on the site shared one itinerary and
525
- the second route page you opened drew the first one's road.
526
- - Stops with no coordinates are dropped rather than plotted at 0,0, which
527
- put a pin off the west coast of Africa and dragged the fitted view
528
- across the Atlantic.
529
- - The query is skipped entirely when no `VITE_OPEN_ROUTE_SERVICE_KEY` is
530
- configured, instead of a guaranteed 401 on every route page view.
531
- - A stray `}` inside the `ButtonChange` styled-component template.
532
-
533
- ## [1.15.2] - 2026-08-01
534
-
535
- ### Added
536
-
537
- - `RouteFeature` takes an optional `compact` prop: icon only, with the name
538
- as a hover tooltip and a help cursor to advertise that the hover does
539
- something. Opt-in, because the browse and route-view pages have the room
540
- and their labels earn it - only the search results card asks for this.
541
-
542
- ### Fixed
543
-
544
- - `RouteFeature`'s image had **no `alt` at all**. That mattered already; it
545
- matters more in compact mode, where the image is the only content and a
546
- screen reader would otherwise announce nothing whatsoever per amenity.
547
-
548
- ## [1.9.0] - 2026-07-31
549
-
550
- ### Added
551
-
552
- - **`Viewer`/`Actions`/`Button` support a second submit-type action** via an
553
- optional `onTest` prop on `Viewer` and a new `'test'` entry in its
554
- `actions` array. A form with two submit buttons can't otherwise tell
555
- which one triggered submission - `Button` now forwards a native
556
- `name`/`value` pair, and `Viewer`'s `onSubmit` reads the submit event's
557
- `submitter` to route to `onTest` instead of `onSave` when the 'test'
558
- button was clicked, while keeping the exact same current form values.
559
- First consumer: `@autobusal/admin-label` 1.10.0's "Test" button on the
560
- SMTP settings tab.
561
-
562
- ## [1.8.3] - 2026-07-31
563
-
564
- ### Fixed
565
-
566
- - **`Meta.tsx`'s `useSoleMetaOwnership` no longer runs on every render.**
567
- It fired on every render of every `<Meta>` usage (136 call sites, no
568
- dependency array) and directly removed `<head>` children via raw DOM
569
- `.remove()` calls each time. The duplicate-tag problem it exists for -
570
- React's `createRoot` not reconciling against tags baked into the
571
- prerendered static HTML - can only exist before React's first render;
572
- every render after that, `<title>`/`<meta>`/`<link>` in `<head>` are
573
- exclusively React 19's own hoisting mechanism's doing, and it already
574
- correctly swaps them out as components mount/unmount with no help
575
- needed. Re-running this raw removal on every navigation instead raced
576
- that hoisting: it could delete a node mid-transition that React's own
577
- reconciler still held a reference to, and the next time React tried to
578
- remove or replace that now-detached node, `parentNode` was null and it
579
- threw an uncaught `TypeError: Cannot read properties of null (reading
580
- 'removeChild')` - not render-phase, so no `ErrorBoundary` catches it,
581
- silently aborting whatever navigation was in flight (URL updates,
582
- page never re-renders). This is the actual root cause of the frozen-
583
- navigation bug worked around piecemeal in `@autobusal/providers`
584
- 1.6.6-1.6.8, `@autobusal/hooks` 1.3.1, and `@autobusal/auth`
585
- 1.5.2-1.5.4 - all of which targeted `GoogleReCaptchaProvider` as a
586
- contributing trigger, but the crash reproduced identically with that
587
- provider removed entirely, on ~any route rendering `<Meta>`. Now
588
- scoped to a module-level flag so the cleanup runs exactly once, on the
589
- app's first `<Meta>` mount - it does its one real job (wiping stale
590
- prerendered tags before first paint) and never touches the DOM again.
591
-
592
- ## [1.8.2] - 2026-07-30
593
-
594
- ### Fixed
595
-
596
- - **`Meta.tsx` duplicate description/canonical/OG/Twitter tags.** The
597
- existing title-only dedup (`useSoleTitleOwnership`, since 1.5.1) is now
598
- `useSoleMetaOwnership` and covers every tag Meta renders, not just
599
- `<title>`. Surfaced by the new build-time prerender step: React doesn't
600
- reconcile against tags already sitting in `<head>` from a source other
601
- than its own current render (a prerendered snapshot's baked-in
602
- description, a leftover from a pre-locale-detection render, ...), so
603
- multiple `<meta name="description">`/`og:description`/etc could coexist
604
- - crawlers commonly read the FIRST occurrence, which could be the stale
605
- one, not the page's real content.
606
-
607
- ## [1.8.1] - 2026-07-30
608
-
609
- ### Fixed
610
-
611
- - **Added `loading="lazy" decoding="async"` to below-the-fold list images**
612
- (`BlogItem/Image.tsx` preview/short thumbnails, `CompanyItem.tsx`,
613
- `RouteItem.tsx` operator logos) - none had lazy loading, so a search
614
- results page or operator/agent listing loaded every logo eagerly
615
- regardless of position. `BlogItem/Image.tsx`'s `type="view"` (the single
616
- article's own hero image, likely that page's LCP element) instead gets
617
- `loading="eager" fetchPriority="high"`.
618
-
619
- ## [1.8.0] - 2026-07-30
620
-
621
- ### Added
622
-
623
- - **GA4 SPA pageview tracking in `Meta`.** gtag.js only auto-sends a
624
- page_view on its initial `config` call - every client-side route change
625
- was invisible to analytics (pairs with `@autobusal/providers` 1.6.2, which
626
- disables the automatic one). Meta now sends an explicit pageview
627
- (`location.pathname + location.search`, current title) whenever the path
628
- changes, since it's already rendered on nearly every real page. Excluded
629
- from the effect's dependencies on purpose: a title-only change (e.g. a
630
- language switch) does not fire a new pageview, only an actual navigation
631
- does.
632
-
633
- ## [1.7.0] - 2026-07-30
634
-
635
- ### Added
636
-
637
- - **New `JsonLd` component** - renders a schema.org `<script type="application/ld+json">` block from a plain object, with `<` escaped to `\u003c` so a string value containing "</script>" can't break out of the tag. No hoisting needed (unlike Meta's title/meta/link) - Google explicitly supports structured data anywhere in the document, so this renders in place. First consumers: `@autobusal/providers`' `Preload.tsx` (sitewide Organization + WebSite) and `route-view`/magus's `BlogView.tsx` (BusTrip/BreadcrumbList, Article).
638
-
639
- ## [1.6.0] - 2026-07-30
640
-
641
- ### Added
642
-
643
- - **`Meta` now emits a canonical `<link>` and `og:url` on every page by
644
- default**, even when the caller passes no `url` prop - previously `url`
645
- was never passed at any of this component's ~136 call sites across both
646
- apps, so no page anywhere had a canonical tag. Defaults to the current
647
- path (`location.pathname`, via `react-router-dom`'s `useLocation`) with
648
- the origin prepended - query strings are naturally excluded, so a search
649
- results page with `?sfrom=X&sto=Y` or a paginated `?page=N` correctly
650
- canonicalizes to its bare path instead of creating unbounded near-duplicate
651
- URLs. `url` can still be passed explicitly to override.
652
-
653
- ## [1.5.1] - 2026-07-30
654
-
655
- ### Fixed
656
-
657
- - **`Meta.tsx` and `@autobusal/providers`'s `Preload.tsx` both render a
658
- `<title>`, and React 19's native hoisting (added in 1.5.0, see above) does
659
- NOT dedupe `<title>` between separate component instances.** Both ended up
660
- as real, simultaneous DOM nodes - technically invalid HTML (a document must
661
- not have more than one title element), even though `document.title` itself
662
- still resolved correctly by spec ("first title in tree order" happened to
663
- always be this one). `Meta` now takes exclusive ownership on mount: it
664
- removes any other `<title>` element in `<head>` the instant its own real
665
- page title exists, so exactly one survives.
666
-
667
- ## [1.5.0] - 2026-07-30
668
-
669
- ### Fixed
670
-
671
- - **`Meta.tsx` migrated off `react-helmet` to React 19's native document
672
- metadata support.** react-helmet 6.1.0 (unmaintained since ~2021) was
673
- discovered to be completely non-functional under React 19: zero of its DOM
674
- writes were ever committing on any route, on any fresh page load, in either
675
- consuming app - confirmed via the absence of `[data-react-helmet]` in the
676
- DOM and by bisecting back through the app's dependency history to before
677
- any of today's changes. Every page's `<title>`, `description`, `robots`,
678
- `og:*`/`twitter:*` tags were silently frozen at whatever the static
679
- `index.html` shipped, for every real user and JS-executing crawler landing
680
- directly on a URL. `<title>`/`<meta>`/`<link>` are now rendered as plain
681
- elements - React 19 auto-hoists and deduplicates them into `<head>` with no
682
- library needed, and the same "deepest/last-rendered wins" semantics Helmet
683
- was supposed to provide (a page's own `<Meta>` overriding
684
- `@autobusal/providers`'s app-shell fallback title) now actually work.
685
-
686
- ## [1.4.7] - 2026-07-30
687
-
688
- ### Fixed
689
-
690
- - **Blog thumbnails missing `alt` text (`BlogItem/Image.tsx`).** Neither the
691
- preview/short grid thumbnail nor the full article image had `alt` - added a
692
- required `title` prop (threaded through from `BlogItem.tsx`'s
693
- `article.title`) used as the `alt` text on both.
694
-
695
- ## [1.4.6] - 2026-07-29
696
-
697
- ### Fixed
698
-
699
- - `Autocomplete/Autocomplete.tsx`: the "From"/"To" route-search inputs kept showing a station's display name in the language that was active when it was selected, since the displayed text (`q`) was plain component state that never resynced when `data` was rebuilt with fresh, relocalized names after a language switch (e.g. "(Alle Stationen)" stayed baked in after switching German -> English). Now re-derives the display name from the selected `value` whenever `data` changes.
700
-
701
- ## [1.4.5] - 2026-07-28
702
-
703
- ### Fixed
704
-
705
- - `File/File.tsx`: a `required` file-input field blocked saving ANY record that already had a file (image, document, etc.) unless the user re-uploaded it, since a file input can never carry over its previous value - the `required` rule now skips when a `value` (existing file URL) is already present. Found via a live sanity pass: this froze several existing records (e.g. a bus with a real image) out of being editable at all.
706
-
707
- ## [1.4.4] - 2026-07-24
708
-
709
- ### Fixed
710
-
711
- - `BlogItem.tsx` and `Policy.tsx`: article/policy content is now run through `@autobusal/utilities`'s `sanitizeHtml` before being rendered via `dangerouslySetInnerHTML`, closing an XSS gap where an already-compromised admin account could persist JS in blog articles or policy pages.
712
-
713
- ## [1.4.3] - 2026-07-19
714
-
715
- ### Changed
716
-
717
- - **`Meta` (`Meta.tsx`) is now brand-configurable, eliminating the per-brand SEO fork.**
718
- Merged magus's rich SEO `Meta` (robots, canonical, Open Graph and Twitter card tags, plus
719
- optional `image`/`url`/`type`/`noIndex` props) into the shared component, and replaced the
720
- hard-coded "BusMagus" strings with a `VITE_APP_NAME` build-time env: the `<title>` suffix,
721
- `og:title`/`twitter:title` and `og:site_name` derive from that brand name, and when it is
722
- unset the title renders bare (no suffix) with `og:site_name` omitted — so a brand that has
723
- not configured a name is unaffected. This lets every whitelabel consume the published
724
- `Meta` and pass its own brand via env, instead of magus vite-aliasing `@autobusal/common`
725
- to a local `src/modules/Common` copy just to override this one file. `children` is now
726
- optional; the prop set is a superset of the previous one, so all existing `<Meta title=…>`
727
- call sites are unaffected.
728
-
729
- ## [1.4.2] - 2026-07-19
730
-
731
- ### Added
732
-
733
- - **Cross-field validation + masked pre-fill for `Password` (`Password/Password.tsx`).** Added
734
- an optional `getValues` prop (react-hook-form's `UseFormGetValues`) that is threaded into
735
- `Validate(...)`, enabling cross-field rules like `matches:password` (e.g. a
736
- password-confirmation field). Added an optional `defaultValue` prop applied to the input so
737
- an admin edit form can render a saved secret as masked dots instead of blank, so editing a
738
- different field no longer wipes the stored password on save.
739
- - **Keyboard accessibility for the calendar day picker (`Calendar/Days.tsx`,
740
- `Calendar/styles.ts`).** Day cells now expose `role="button"`, `tabIndex` (`-1` when
741
- disabled), `aria-disabled`, `aria-pressed`, and an `onKeyDown` handler that activates on
742
- Enter/Space, plus a `:focus-visible` outline. Days are now selectable via keyboard, not
743
- only mouse.
744
- - **Debounced type-to-filter search (`Table/Top/Search.tsx`).** The table search input now
745
- submits automatically ~500ms after the user stops typing (via a `useEffect`/`setTimeout`
746
- with an initial-render guard using `useRef`), instead of requiring an Enter press that
747
- nothing on screen advertised. Explicit Enter submit is preserved.
748
-
749
- ### Changed
750
-
751
- - **`Password` pre-fill wired through the dynamic viewer (`Viewer/Data.tsx`).** The `password`
752
- field case now passes `defaultValue={ value.string }` to `Password`, so saved password
753
- values hydrate as masked input in generated forms.
754
- - **Confirm-password persistence of language selection (`ChangeLanguage/ChangeLanguage.tsx`).**
755
- Language changes are now persisted to `localStorage['language']` so the chosen language
756
- survives reloads rather than resetting to the default.
757
- - **Explicit `common` namespace on i18n lookups (`Gender.tsx`, `Seats/ChooseSeat.tsx`).**
758
- Translation calls now pass `{ ns: 'common' }` (gender male/female labels; seat
759
- chosen/choose labels) so the correct namespace resolves and the raw key is no longer shown
760
- when the component is used outside the default namespace context.
761
- - **Styled Calendar browse dropdown (`Calendar/Browse/styles.ts`).** The select now has a
762
- visible border, border-radius, padding, pointer cursor, and a custom chevron background,
763
- replacing `border: none` so it reads as an interactive control.
764
-
765
- ### Fixed
766
-
767
- - **Missing image alt text (`Avatar.tsx`, `CompanyItem/CompanyItem.tsx`, `RouteItem/RouteItem.tsx`).**
768
- Operator/company logo `<img>` tags now include `alt={ ...company }`, giving screen readers a
769
- meaningful label and a fallback when the logo fails to load.
770
-
771
- Authored by Ferjolt Ozuni. Consolidated from the magus and alvavel whitelabel patch sets into canonical @autobusal source (eliminates per-repo patch-package divergence).