@autobusal/common 1.15.7 → 1.17.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/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ All notable changes to `@autobusal/common` are documented here. This project fol
6
6
  > Note: 1.10.0 through 1.15.1 were published without changelog entries. The
7
7
  > gap is left as-is rather than reconstructed after the fact.
8
8
 
9
+ ## [1.17.0] - 2026-08-02
10
+
11
+ ### Added
12
+
13
+ - **Locale-prefixed URLs.** `localiseRoutes()` mirrors an absolute-path route
14
+ tree under `/:locale`, and `LocaleGate` guards that branch - rejecting a
15
+ prefix that is not one of the brand's languages, redirecting the default
16
+ language back to its unprefixed home so only one URL is canonical, and
17
+ switching i18n to whatever the URL asks for. `splitLocale`/`withLocale`/
18
+ `localeFromPath` are the shared, router-free definition of a localised
19
+ path, so the crawler and the sitemap agree with the components.
20
+ - **hreflang alternates on every page.** Emitted from `Meta`, which already
21
+ sits on ~136 call sites, so every real page gains them at once and a new
22
+ page cannot forget them. `x-default` points at the unprefixed URL.
23
+ Suppressed for noindex pages and whenever a caller passes an explicit
24
+ canonical, which the path-derived alternates would contradict.
25
+
26
+ ### Changed
27
+
28
+ - **`ChangeLanguage` navigates** instead of only swapping the rendered text -
29
+ behind an explicit `localised` prop, so the account and admin areas (which
30
+ are deliberately not locale-prefixed) keep switching in place rather than
31
+ navigating to a 404.
32
+ - **The canonical drops a trailing slash.** Apache serves a prerendered page
33
+ from a directory and 301s `/contact` to `/contact/`, so the same page used
34
+ to advertise whichever form the visitor happened to arrive on. Harmless
35
+ when it was the only path-derived tag; not harmless now that the alternates
36
+ come from the same value, because one page would announce two URL sets.
37
+
38
+ ## [1.16.0] - 2026-08-01
39
+
40
+ ### Added
41
+
42
+ - **The date field can tell its form when the day changes.** `ViewData.onChange`
43
+ was already honoured by `select` and `checkbox`, but not by `date`/`dob`/
44
+ `picker`, so a form whose other fields depend on the chosen day had no way
45
+ to observe it short of not using `Viewer` at all. `Calendar` now takes an
46
+ optional `onChange(DD/MM/YYYY)` and `Viewer` passes `item.onChange` through
47
+ to it. Purely additive - existing callers are unaffected.
48
+
9
49
  ## [1.15.6] - 2026-08-01
10
50
 
11
51
  ### Changed
@@ -22,9 +22,15 @@ interface Props {
22
22
  minDate?: string
23
23
  refs: UseFormRegister<any>
24
24
  onUpdate: UseFormSetValue<any>
25
+ /**
26
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
27
+ * Notified with the newly picked DD/MM/YYYY day, for callers that need to
28
+ * react to it rather than only read it back at submit time.
29
+ */
30
+ onChange?: (value: string) => void
25
31
  }
26
32
 
27
- const Calendar = ({ type, name, defaultValue, t, validation, minDate, refs, onUpdate }: Props): JSX.Element => {
33
+ const Calendar = ({ type, name, defaultValue, t, validation, minDate, refs, onUpdate, onChange }: Props): JSX.Element => {
28
34
  const [ show, setShow ] = useState<boolean>(false);
29
35
  const [ prepared, setPrepared ] = useState<string>(defaultValue ?? prepareDate(new Date()));
30
36
  const [ selected, setSelected ] = useState<string>(prepared);
@@ -40,6 +46,8 @@ const Calendar = ({ type, name, defaultValue, t, validation, minDate, refs, onUp
40
46
  setPrepared(prepared);
41
47
 
42
48
  onUpdate(name, prepared);
49
+
50
+ onChange?.(prepared);
43
51
  };
44
52
 
45
53
  return (
@@ -1,7 +1,9 @@
1
1
  import { useRef } from 'react';
2
2
  import { TFunction, i18n } from 'i18next';
3
+ import { useLocation, useNavigate } from 'react-router-dom';
3
4
  import { useOutside } from '@autobusal/hooks';
4
5
  import Flag from './Flag';
6
+ import { withLocale } from '../Locale/locale';
5
7
  import { Container, Title, ContainerButtons, ButtonChange } from './styles';
6
8
  import { useGetSettings } from '@autobusal/providers/services';
7
9
 
@@ -9,24 +11,64 @@ interface Props {
9
11
  type: 'top' | 'bottom'
10
12
  t: TFunction<'public'>
11
13
  i18n: i18n
14
+ /**
15
+ * Edited: Ferjolt Ozuni - Date: 2026-08-02
16
+ * Whether the page this is rendered on has a locale-prefixed URL. Only
17
+ * the public tree does; the account/admin areas are deliberately not
18
+ * localised (noindex, behind a login), so navigating there would land on
19
+ * a 404. Defaults to false so that any call site not explicitly opting in
20
+ * keeps the previous switch-in-place behaviour rather than silently
21
+ * gaining a broken link.
22
+ */
23
+ localised?: boolean
12
24
  onClose: () => void
13
25
  }
14
26
 
15
- const ChangeLanguage = ({ type, t, i18n, onClose }: Props): JSX.Element => {
27
+ const ChangeLanguage = ({ type, t, i18n, localised = false, onClose }: Props): JSX.Element => {
16
28
  const ref = useRef<HTMLDivElement>(null);
17
29
 
18
30
  const { data } = useGetSettings();
19
31
 
32
+ const location = useLocation();
33
+
34
+ const navigate = useNavigate();
35
+
20
36
  useOutside(ref, onClose);
21
37
 
38
+ const available = data.preferences.languages.available;
39
+
40
+ const fallback = data.preferences.languages.default;
41
+
42
+ /**
43
+ * Edited: Ferjolt Ozuni - Date: 2026-08-02
44
+ *
45
+ * Switching language now NAVIGATES. It used to change i18n state and stop
46
+ * there, which left one URL serving every language: nothing to link to,
47
+ * nothing for hreflang to point at, and only one of the thirteen could
48
+ * ever be indexed.
49
+ *
50
+ * Only pages that HAVE a localised URL are navigated. The account, admin
51
+ * and auth areas are deliberately not locale-prefixed - they are noindex
52
+ * and behind a login, so a per-language URL buys nothing - and sending
53
+ * somebody from /account/orders to /it/account/orders would land them on a
54
+ * 404. There, the old behaviour is still exactly right: switch the
55
+ * language in place and stay put.
56
+ */
22
57
  const onChange = (language: string): void => {
23
58
  i18n.changeLanguage(language);
24
59
  localStorage.setItem('language', language);
60
+
61
+ if (localised) {
62
+ const to = withLocale(location.pathname, language, fallback, available);
63
+
64
+ if (to !== location.pathname) {
65
+ navigate(`${ to }${ location.search }${ location.hash }`);
66
+ }
67
+ }
68
+
25
69
  onClose();
26
70
  };
27
71
 
28
- const available = data.preferences.languages.available;
29
-
30
72
  // Edited: Ferjolt Ozuni - Date: 2026-08-01
31
73
  // Just the language name. A "Change to" prefix on every row said nothing
32
74
  // the panel's own title and the flag beside it were not already saying,
@@ -51,4 +93,4 @@ const ChangeLanguage = ({ type, t, i18n, onClose }: Props): JSX.Element => {
51
93
  );
52
94
  };
53
95
 
54
- export default ChangeLanguage;
96
+ export default ChangeLanguage;
@@ -0,0 +1,90 @@
1
+ import { useEffect } from 'react';
2
+ import { Navigate, useLocation } from 'react-router-dom';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { splitLocale } from './locale';
5
+ import { useGetSettings } from '@autobusal/providers/services';
6
+
7
+ interface Props {
8
+ children: JSX.Element
9
+ }
10
+
11
+ /**
12
+ * Guards the `/:locale` branch of the router and keeps i18n pointed at
13
+ * whatever the URL says.
14
+ *
15
+ * Ferjolt Ozuni - Date: 2026-08-02
16
+ *
17
+ * Three jobs, in order:
18
+ *
19
+ * 1. Reject a prefix that is not one of this brand's languages. `:locale`
20
+ * matches any single segment, so without this `/nonsense/city/tirana`
21
+ * would render the city page as though nothing were wrong - a
22
+ * duplicate-content URL for every page, generated by anyone who
23
+ * mistypes. Those go to the app's own not-found handling instead.
24
+ *
25
+ * 2. Send the DEFAULT language back to its unprefixed home. `/en/contact`
26
+ * and `/contact` are the same page, and only one of them may be the
27
+ * canonical, so the prefixed form redirects rather than being quietly
28
+ * served (and indexed) alongside it.
29
+ *
30
+ * 3. Switch i18n to the URL's language. The URL has to win over the stored
31
+ * preference: the entire point of localised URLs is that a link decides
32
+ * what the recipient sees, and someone who last browsed in Albanian
33
+ * opening a shared Italian link must get Italian.
34
+ *
35
+ * The stored preference is updated too, so leaving a localised page for an
36
+ * unprefixed one (the account area, say) keeps the language the visitor was
37
+ * actually reading.
38
+ */
39
+ const LocaleGate = ({ children }: Props): JSX.Element => {
40
+ const location = useLocation();
41
+
42
+ const { i18n } = useTranslation();
43
+
44
+ const { data } = useGetSettings();
45
+
46
+ const available = data?.preferences?.languages?.available ?? [];
47
+
48
+ const fallback = data?.preferences?.languages?.default ?? 'en';
49
+
50
+ // Read from the path rather than useParams: this renders as the LAYOUT
51
+ // element of the route whose CHILDREN carry the :locale segment, and a
52
+ // parent match does not expose a child's params. The path is the same
53
+ // source of truth either way, and one fewer thing to keep in step with the
54
+ // route shape.
55
+ const [ , segment ] = location.pathname.split('/');
56
+
57
+ const locale = segment ?? '';
58
+
59
+ const known = available.includes(locale);
60
+
61
+ const redundant = known && locale === fallback;
62
+
63
+ useEffect(() => {
64
+ if (!known || redundant) {
65
+ return;
66
+ }
67
+
68
+ if (i18n.language !== locale) {
69
+ i18n.changeLanguage(locale);
70
+ }
71
+
72
+ localStorage.setItem('language', locale);
73
+ }, [ known, redundant, locale, i18n ]);
74
+
75
+ if (!known) {
76
+ // not a language - let the app's normal not-found path handle it rather
77
+ // than inventing a second way to say the same thing
78
+ throw new Response('Not Found', { status: 404 });
79
+ }
80
+
81
+ if (redundant) {
82
+ const { path } = splitLocale(location.pathname, available);
83
+
84
+ return <Navigate to={ `${ path === '' ? '/' : path }${ location.search }${ location.hash }` } replace />;
85
+ }
86
+
87
+ return children;
88
+ };
89
+
90
+ export default LocaleGate;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Locale-prefixed URLs: /{locale}/whatever, with the default language left
3
+ * unprefixed.
4
+ *
5
+ * Ferjolt Ozuni - Date: 2026-08-02
6
+ *
7
+ * Until now switching language changed only what was rendered - the URL
8
+ * never moved. One URL therefore served every language, so a search engine
9
+ * could index exactly one of them, there were no alternate URLs for
10
+ * hreflang to point at, and a link to "this page in Italian" did not exist.
11
+ *
12
+ * The DEFAULT language stays unprefixed on purpose. Prefixing everything
13
+ * would be tidier to read, but it would change every URL that is already
14
+ * indexed and already earning, and force a redirect on the majority of
15
+ * traffic to gain nothing a canonical does not already say. So `/city/tirana`
16
+ * remains the English page and `/it/city/tirana` is its Italian counterpart.
17
+ *
18
+ * These helpers are deliberately pure and router-free so the prerender
19
+ * crawler, the sitemap comparison and the components can all share one
20
+ * definition of what a localised path is.
21
+ */
22
+
23
+ /**
24
+ * Pull a locale prefix off a path, if the first segment is one of the
25
+ * brand's languages.
26
+ *
27
+ * Only ever treats a segment as a locale when it is in `available` - which
28
+ * is what stops `/city/tirana` being read as locale `city`, and what makes
29
+ * an unknown prefix land on a real 404 rather than silently rendering the
30
+ * homepage in the default language.
31
+ */
32
+ export const splitLocale = (pathname: string, available: string[]): { locale?: string, path: string } => {
33
+ const [ , first, ...rest ] = pathname.split('/');
34
+
35
+ if (first && available.includes(first)) {
36
+ return { locale: first, path: '/' + rest.join('/') };
37
+ }
38
+
39
+ return { path: pathname };
40
+ };
41
+
42
+ /**
43
+ * The path a page should live at in a given language.
44
+ *
45
+ * Round-trips with splitLocale: the default language returns the bare path,
46
+ * everything else gets its prefix. Query and hash are the caller's business -
47
+ * this only ever rewrites the pathname.
48
+ */
49
+ export const withLocale = (pathname: string, locale: string, defaultLocale: string, available: string[]): string => {
50
+ const { path } = splitLocale(pathname, available);
51
+
52
+ const clean = path === '' ? '/' : path;
53
+
54
+ if (locale === defaultLocale) {
55
+ return clean;
56
+ }
57
+
58
+ return clean === '/' ? `/${ locale }` : `/${ locale }${ clean }`;
59
+ };
60
+
61
+ /**
62
+ * Which language a URL is asking for, falling back to the stored preference
63
+ * and then the brand default.
64
+ *
65
+ * The URL wins deliberately: once a page can be linked to per language, the
66
+ * link has to be what decides, or somebody who last browsed in Albanian
67
+ * would open a shared Italian link and be shown Albanian.
68
+ */
69
+ export const localeFromPath = (pathname: string, available: string[], defaultLocale: string, stored?: string | null): string => {
70
+ const { locale } = splitLocale(pathname, available);
71
+
72
+ if (locale) {
73
+ return locale;
74
+ }
75
+
76
+ if (stored && available.includes(stored)) {
77
+ return stored;
78
+ }
79
+
80
+ return defaultLocale;
81
+ };
@@ -0,0 +1,42 @@
1
+ import { RouteObject } from 'react-router-dom';
2
+
3
+ /**
4
+ * Mirror a public route tree under a `/:locale` prefix.
5
+ *
6
+ * Ferjolt Ozuni - Date: 2026-08-02
7
+ *
8
+ * Both apps declare their public routes with ABSOLUTE paths ('/city/:slug'),
9
+ * nested children included, so the same tree cannot simply be dropped under
10
+ * a parent path - every path has to be rewritten. Doing that here, from the
11
+ * one existing definition, is what keeps the localised tree from drifting:
12
+ * a page added to the public routes is localised automatically, and there is
13
+ * no second list to forget to update.
14
+ *
15
+ * Route ranking does the disambiguation for us. React Router scores a static
16
+ * segment above a dynamic one, so `/admin/...`, `/account/...` and every
17
+ * other real static route still win against `:locale`; only a first segment
18
+ * that matches nothing else can fall through to it. LocaleGate then rejects
19
+ * anything that is not actually one of the brand's languages, so an unknown
20
+ * prefix 404s instead of quietly rendering the homepage.
21
+ */
22
+ const localise = (routes: RouteObject[]): RouteObject[] => (
23
+ routes.map(route => {
24
+ const localised: RouteObject = { ...route };
25
+
26
+ if (typeof route.path === 'string') {
27
+ // '' is the index route (the homepage) - under a locale that is the
28
+ // bare '/:locale', not '/:locale/'
29
+ localised.path = route.path === '' || route.path === '/'
30
+ ? '/:locale'
31
+ : `/:locale${ route.path.startsWith('/') ? '' : '/' }${ route.path }`;
32
+ }
33
+
34
+ if (route.children) {
35
+ localised.children = localise(route.children);
36
+ }
37
+
38
+ return localised;
39
+ })
40
+ );
41
+
42
+ export default localise;
package/Meta.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useEffect } from 'react';
2
2
  import { useLocation } from 'react-router-dom';
3
+ import { splitLocale, withLocale } from './Locale/locale';
3
4
  import { pageview } from '@autobusal/providers/Setup/analytics';
4
5
  import { useGetSettings } from '@autobusal/providers/services';
5
6
 
@@ -50,7 +51,14 @@ const withBrand = (value: string): string => (brand ? `${ value } - ${ brand }`
50
51
  // every other element matching the same selector; if it has no value for
51
52
  // this page (description/keywords/image are optional), remove all of
52
53
  // them, since nothing here can claim the page has one.
53
- type OwnedTag = { selector: string, attr: 'text' | string, value?: string };
54
+ // Edited: Ferjolt Ozuni - Date: 2026-08-02
55
+ // `values` (plural) is for tags that legitimately repeat - the hreflang
56
+ // alternates. With a single `value` the cleanup below keeps one element and
57
+ // deletes the rest, which for alternates would throw away every language but
58
+ // one. With `values` it keeps every element whose attribute is in the set,
59
+ // which is exactly React's own current output, and removes only the leftovers
60
+ // a prerendered snapshot brought with it.
61
+ type OwnedTag = { selector: string, attr: 'text' | string, value?: string, values?: string[] };
54
62
 
55
63
  // Edited: Ferjolt Ozuni - Date: 2026-07-31
56
64
  // Bug: this ran on EVERY render of EVERY <Meta> usage (no dependency array),
@@ -86,9 +94,21 @@ const useSoleMetaOwnership = (tags: OwnedTag[]): void => {
86
94
 
87
95
  hasCleanedPrerenderedTags = true;
88
96
 
89
- tags.forEach(({ selector, attr, value }) => {
97
+ tags.forEach(({ selector, attr, value, values }) => {
90
98
  const elements = [...document.querySelectorAll(`head > ${ selector }`)];
91
99
 
100
+ if (values !== undefined) {
101
+ const keep = new Set(values);
102
+
103
+ elements.forEach(el => {
104
+ if (!keep.has(el.getAttribute(attr) ?? '')) {
105
+ el.remove();
106
+ }
107
+ });
108
+
109
+ return;
110
+ }
111
+
92
112
  if (value === undefined) {
93
113
  elements.forEach(el => el.remove());
94
114
  return;
@@ -118,10 +138,27 @@ const useSoleMetaOwnership = (tags: OwnedTag[]): void => {
118
138
  // to its bare /bus-lines/{from}/{to}/... path, and a static page's own path
119
139
  // is already the correct canonical. Callers can still pass `url` explicitly
120
140
  // to override (e.g. to canonicalize a paginated URL back to page 1).
141
+ /**
142
+ * Edited: Ferjolt Ozuni - Date: 2026-08-02
143
+ * The trailing slash is normalised away first.
144
+ *
145
+ * Apache serves a prerendered page out of a directory, so it 301s
146
+ * /contact to /contact/ - which means the same page is reachable at two
147
+ * paths and, before this, advertised whichever one the visitor happened to
148
+ * arrive on as its canonical. Harmless while that was the only tag derived
149
+ * from the path; not harmless now that the hreflang alternates are built
150
+ * from the same value, because /ro/contact and /ro/contact/ would be
151
+ * announced as different URLs for one page - the exact duplication
152
+ * localised URLs exist to remove. One form, always, whichever door was used.
153
+ */
154
+ const normalisePath = (pathname: string): string => (
155
+ pathname.length > 1 && pathname.endsWith('/') ? pathname.replace(/\/+$/, '') : pathname
156
+ );
157
+
121
158
  const useCanonicalUrl = (explicit?: string): string => {
122
159
  const location = useLocation();
123
160
 
124
- return explicit ?? `${ window.location.origin }${ location.pathname }`;
161
+ return explicit ?? `${ window.location.origin }${ normalisePath(location.pathname) }`;
125
162
  };
126
163
 
127
164
  // A single-page app never reloads, so GTM's page-load trigger fires exactly
@@ -152,18 +189,69 @@ const usePageviewTracking = (path: string, title: string): void => {
152
189
  }, [container, path]);
153
190
  };
154
191
 
192
+ /**
193
+ * Every language this page exists in, as rel="alternate" hreflang links.
194
+ *
195
+ * Ferjolt Ozuni - Date: 2026-08-02
196
+ *
197
+ * Emitted from here rather than per page for the same reason the canonical
198
+ * is: this component is already on ~136 call sites across both apps, so
199
+ * hreflang lands on every real page at once and cannot be forgotten on a new
200
+ * one. hreflang is only meaningful when each language has its own URL, which
201
+ * is what the /{locale} prefix introduced.
202
+ *
203
+ * `x-default` points at the unprefixed URL - the default language - which is
204
+ * what a search engine should show when it has no better signal about the
205
+ * visitor's language.
206
+ *
207
+ * Suppressed for noindex pages: they have nothing to offer alternates for,
208
+ * and telling a crawler about language variants of a page it has just been
209
+ * asked not to index is noise at best.
210
+ */
211
+ const useAlternates = (noIndex: boolean, explicitUrl?: string): { locale: string, href: string }[] => {
212
+ const location = useLocation();
213
+
214
+ const { data } = useGetSettings();
215
+
216
+ const available = data?.preferences?.languages?.available ?? [];
217
+
218
+ const fallback = data?.preferences?.languages?.default ?? 'en';
219
+
220
+ // an explicit canonical means the caller is pointing somewhere other than
221
+ // the current path (a paginated page folding back to page 1, say) - the
222
+ // path-derived alternates below would contradict it
223
+ if (noIndex || explicitUrl || available.length < 2) {
224
+ return [];
225
+ }
226
+
227
+ const origin = window.location.origin;
228
+
229
+ const { path } = splitLocale(normalisePath(location.pathname), available);
230
+
231
+ return [
232
+ { locale: 'x-default', href: `${ origin }${ withLocale(path, fallback, fallback, available) }` },
233
+ ...available.map(locale => ({
234
+ locale,
235
+ href: `${ origin }${ withLocale(path, locale, fallback, available) }`
236
+ }))
237
+ ];
238
+ };
239
+
155
240
  const Meta = ({ title, keywords, description, image, url, type = 'website', noIndex = false, children }: Props): JSX.Element => {
156
241
  const fullTitle = withBrand(title);
157
242
  const canonicalUrl = useCanonicalUrl(url);
158
243
  const location = useLocation();
159
244
  const robots = noIndex ? 'noindex, nofollow' : 'index, follow';
160
245
 
246
+ const alternates = useAlternates(noIndex, url);
247
+
161
248
  useSoleMetaOwnership([
162
249
  { selector: 'title', attr: 'text', value: fullTitle },
163
250
  { selector: 'meta[name="robots"]', attr: 'content', value: robots },
164
251
  { selector: 'meta[name="keywords"]', attr: 'content', value: keywords },
165
252
  { selector: 'meta[name="description"]', attr: 'content', value: description },
166
253
  { selector: 'link[rel="canonical"]', attr: 'href', value: canonicalUrl },
254
+ { selector: 'link[rel="alternate"][hreflang]', attr: 'href', values: alternates.map(item => item.href) },
167
255
  { selector: 'meta[property="og:type"]', attr: 'content', value: type },
168
256
  { selector: 'meta[property="og:title"]', attr: 'content', value: fullTitle },
169
257
  { selector: 'meta[property="og:description"]', attr: 'content', value: description },
@@ -188,6 +276,9 @@ const Meta = ({ title, keywords, description, image, url, type = 'website', noIn
188
276
  { keywords && <meta name="keywords" content={ keywords } /> }
189
277
  { description && <meta name="description" content={ description } /> }
190
278
  <link rel="canonical" href={ canonicalUrl } />
279
+ { alternates.map(item => (
280
+ <link key={ item.locale } rel="alternate" hrefLang={ item.locale } href={ item.href } />
281
+ )) }
191
282
 
192
283
  {/* Open Graph / Facebook */}
193
284
  <meta property="og:type" content={ type } />
package/Road/Road.tsx ADDED
@@ -0,0 +1,53 @@
1
+ import { useState } from 'react';
2
+ import { RiBus2Line } from 'react-icons/ri';
3
+ import { Container, Bus, Vehicle, Fallback } from './styles';
4
+
5
+ interface Props {
6
+ /**
7
+ * Brand asset root (SettingsData.url). The bus is served from the label's
8
+ * own storage, exactly like its logo and hero artwork, so a whitelabel can
9
+ * ship its own vehicle without a code change.
10
+ */
11
+ url?: string
12
+ }
13
+
14
+ /**
15
+ * A road with a bus travelling along it.
16
+ *
17
+ * Ferjolt Ozuni - Date: 2026-08-01
18
+ *
19
+ * Shared by the under-construction holding page and the error pages, which
20
+ * had been growing their own copies of the same CSS.
21
+ *
22
+ * The vehicle is an image from label storage rather than a bundled asset,
23
+ * for the same reason the logo is: every brand gets its own without a
24
+ * rebuild. If the brand has not supplied one the icon stands in, so a
25
+ * missing file degrades to what the page looked like before rather than
26
+ * leaving a hole where a bus should be.
27
+ *
28
+ * Entirely decorative - the caller marks it aria-hidden.
29
+ */
30
+ const Road = ({ url }: Props): JSX.Element => {
31
+ const [ failed, setFailed ] = useState<boolean>(false);
32
+
33
+ return (
34
+ <Container>
35
+ <Bus>
36
+ { url !== undefined && !failed
37
+ ? (
38
+ /* WebP first (10KB against the PNG's 66KB), with the PNG as the
39
+ fallback source and the icon as the fallback for a brand that
40
+ has not supplied artwork at all */
41
+ <picture>
42
+ <source srcSet={ `${ url }/bus-artwork.webp` } type="image/webp" />
43
+
44
+ <Vehicle src={ `${ url }/bus-artwork.png` } alt="" onError={ () => setFailed(true) } />
45
+ </picture>
46
+ )
47
+ : <Fallback><RiBus2Line /></Fallback> }
48
+ </Bus>
49
+ </Container>
50
+ );
51
+ };
52
+
53
+ export default Road;
package/Road/styles.ts ADDED
@@ -0,0 +1,87 @@
1
+ import styled, { keyframes } from 'styled-components';
2
+
3
+ const drift = keyframes`
4
+ from { transform: translateX(0); }
5
+ to { transform: translateX(-84px); }
6
+ `;
7
+
8
+ const travel = keyframes`
9
+ from { transform: translateX(-26vw); }
10
+ to { transform: translateX(114vw); }
11
+ `;
12
+
13
+ /**
14
+ * Ferjolt Ozuni - Date: 2026-08-01
15
+ *
16
+ * A band of road across the foot of the page. Lifted out of
17
+ * UnderConstruction so the error pages could use the same one instead of a
18
+ * second copy of the same CSS.
19
+ */
20
+ export const Container = styled.div`
21
+ position: absolute;
22
+ right: 0;
23
+ bottom: 0;
24
+ left: 0;
25
+ height: 150px;
26
+ overflow: hidden;
27
+ background: linear-gradient(
28
+ to top,
29
+ ${ props => props.theme.primary.normal }22,
30
+ transparent
31
+ );
32
+
33
+ &::after {
34
+ content: '';
35
+ position: absolute;
36
+ bottom: 46px;
37
+ left: 0;
38
+ width: 200%;
39
+ height: 3px;
40
+ border-radius: 3px;
41
+ background: repeating-linear-gradient(
42
+ to right,
43
+ ${ props => props.theme.primary.normal }66 0 42px,
44
+ transparent 42px 84px
45
+ );
46
+ animation: ${ drift } 6s linear infinite;
47
+ }
48
+
49
+ /* a moving road is exactly the kind of thing that should stop for anyone
50
+ who has asked motion to stop */
51
+ @media (prefers-reduced-motion: reduce) {
52
+ &::after {
53
+ animation: none;
54
+ }
55
+ }
56
+ `;
57
+
58
+ export const Bus = styled.div`
59
+ position: absolute;
60
+ bottom: 44px;
61
+ left: 0;
62
+ display: flex;
63
+ align-items: flex-end;
64
+ animation: ${ travel } 18s linear infinite;
65
+
66
+ /* parked, not removed - the scene should still make sense standing still */
67
+ @media (prefers-reduced-motion: reduce) {
68
+ animation: none;
69
+ transform: translateX(10vw);
70
+ }
71
+ `;
72
+
73
+ export const Vehicle = styled.img`
74
+ display: block;
75
+ width: 150px;
76
+ height: auto;
77
+
78
+ @media (min-width: 768px) {
79
+ width: 200px;
80
+ }
81
+ `;
82
+
83
+ export const Fallback = styled.span`
84
+ display: flex;
85
+ font-size: 44px;
86
+ color: ${ props => props.theme.primary.normal };
87
+ `;
@@ -1,6 +1,7 @@
1
1
  import { useEffect } from 'react';
2
2
  import { TFunction } from 'i18next';
3
- import { Screen, Content, Logo, Title, Message, Road } from './styles';
3
+ import Road from '../Road/Road';
4
+ import { Screen, Content, Logo, Title, Message } from './styles';
4
5
  import { useGetSettings } from '@autobusal/providers/services';
5
6
 
6
7
  interface Props {
@@ -75,7 +76,12 @@ const UnderConstruction = ({ t }: Props): JSX.Element => {
75
76
 
76
77
  </Content>
77
78
 
78
- <Road aria-hidden="true" />
79
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
80
+ Shared with the error pages now, and carrying the brand's bus
81
+ rather than a bare strip of road. */ }
82
+ <div aria-hidden="true">
83
+ <Road url={ settings?.url } />
84
+ </div>
79
85
  </Screen>
80
86
  );
81
87
  };
@@ -11,11 +11,6 @@ import styled, { keyframes } from 'styled-components';
11
11
  * rather than a generic maintenance page.
12
12
  */
13
13
 
14
- const drift = keyframes`
15
- from { transform: translate3d(0, 0, 0); }
16
- to { transform: translate3d(-50%, 0, 0); }
17
- `;
18
-
19
14
  const rise = keyframes`
20
15
  from { opacity: 0; transform: translateY(14px); }
21
16
  to { opacity: 1; transform: translateY(0); }
@@ -72,44 +67,3 @@ export const Message = styled.p`
72
67
  color: ${ props => props.theme.font.faded };
73
68
  `;
74
69
 
75
- /**
76
- * A slow band of road at the foot of the page. Purely decorative, and
77
- * hidden from assistive tech - it carries no information.
78
- */
79
- export const Road = styled.div`
80
- position: absolute;
81
- right: 0;
82
- bottom: 0;
83
- left: 0;
84
- height: 96px;
85
- opacity: 0.5;
86
- background: linear-gradient(
87
- to top,
88
- ${ props => props.theme.primary.normal }22,
89
- transparent
90
- );
91
-
92
- &::after {
93
- content: '';
94
- position: absolute;
95
- bottom: 34px;
96
- left: 0;
97
- width: 200%;
98
- height: 3px;
99
- border-radius: 3px;
100
- background: repeating-linear-gradient(
101
- to right,
102
- ${ props => props.theme.primary.normal }66 0 42px,
103
- transparent 42px 84px
104
- );
105
- animation: ${ drift } 6s linear infinite;
106
- }
107
-
108
- /* a moving dashed line is exactly the kind of thing that should stop for
109
- anyone who has asked motion to stop */
110
- @media (prefers-reduced-motion: reduce) {
111
- &::after {
112
- animation: none;
113
- }
114
- }
115
- `;
package/Viewer/Data.tsx CHANGED
@@ -83,6 +83,12 @@ const Data = ({ id, item, refs, t, onUpdate }: Props): (JSX.Element | null) => {
83
83
  validation={ item.rules }
84
84
  refs={ refs }
85
85
  onUpdate={ onUpdate }
86
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
87
+ // `select` and `checkbox` already surfaced item.onChange; date did
88
+ // not, so a form whose other fields react to the chosen day (e.g.
89
+ // the Telegram broadcast's live recipient count) had no way to
90
+ // observe it short of not using Viewer at all.
91
+ onChange={ item.onChange }
86
92
  />
87
93
  );
88
94
 
package/index.ts CHANGED
@@ -29,6 +29,10 @@ import Policy from './Policy/Policy';
29
29
  import Report from './Report/Report';
30
30
  import RouteFeature from './RouteFeature/RouteFeature';
31
31
  import Required from './Required/Required';
32
+ import Road from './Road/Road';
33
+ import LocaleGate from './Locale/LocaleGate';
34
+ import localiseRoutes from './Locale/routes';
35
+ import { splitLocale, withLocale, localeFromPath } from './Locale/locale';
32
36
  import RouteItem from './RouteItem/RouteItem';
33
37
  import Row from './Table/Row';
34
38
  import Table from './Table/Table';
@@ -72,6 +76,12 @@ export {
72
76
  Report,
73
77
  RouteFeature,
74
78
  Required,
79
+ Road,
80
+ LocaleGate,
81
+ localiseRoutes,
82
+ splitLocale,
83
+ withLocale,
84
+ localeFromPath,
75
85
  RouteItem,
76
86
  Row,
77
87
  Table,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.15.7",
3
+ "version": "1.17.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"