@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 +40 -0
- package/Calendar/Calendar.tsx +9 -1
- package/ChangeLanguage/ChangeLanguage.tsx +46 -4
- package/Locale/LocaleGate.tsx +90 -0
- package/Locale/locale.ts +81 -0
- package/Locale/routes.ts +42 -0
- package/Meta.tsx +94 -3
- package/Road/Road.tsx +53 -0
- package/Road/styles.ts +87 -0
- package/UnderConstruction/UnderConstruction.tsx +8 -2
- package/UnderConstruction/styles.ts +0 -46
- package/Viewer/Data.tsx +6 -0
- package/index.ts +10 -0
- package/package.json +1 -1
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
|
package/Calendar/Calendar.tsx
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;
|
package/Locale/locale.ts
ADDED
|
@@ -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
|
+
};
|
package/Locale/routes.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|