@autobusal/common 1.16.0 → 1.18.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 +42 -0
- 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/SeoOverride/SeoOverride.tsx +115 -0
- package/SeoOverride/styles.ts +88 -0
- package/index.ts +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,48 @@ 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.18.0] - 2026-08-02
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`SeoOverride`** - a per-language editor for the SEO title/description
|
|
14
|
+
overrides, shared by the label, country and city forms. One language is
|
|
15
|
+
shown at a time (a brand writes copy for one or two, and thirteen sets of
|
|
16
|
+
empty inputs invite nobody to fill any of them in) and the languages that
|
|
17
|
+
already have copy are marked, so it is obvious which are done. Controlled
|
|
18
|
+
and deliberately form-less: those pages submit every field in one request,
|
|
19
|
+
so an editor with its own save would either post a partial update or leave
|
|
20
|
+
two save buttons meaning different things on one screen.
|
|
21
|
+
|
|
22
|
+
## [1.17.0] - 2026-08-02
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Locale-prefixed URLs.** `localiseRoutes()` mirrors an absolute-path route
|
|
27
|
+
tree under `/:locale`, and `LocaleGate` guards that branch - rejecting a
|
|
28
|
+
prefix that is not one of the brand's languages, redirecting the default
|
|
29
|
+
language back to its unprefixed home so only one URL is canonical, and
|
|
30
|
+
switching i18n to whatever the URL asks for. `splitLocale`/`withLocale`/
|
|
31
|
+
`localeFromPath` are the shared, router-free definition of a localised
|
|
32
|
+
path, so the crawler and the sitemap agree with the components.
|
|
33
|
+
- **hreflang alternates on every page.** Emitted from `Meta`, which already
|
|
34
|
+
sits on ~136 call sites, so every real page gains them at once and a new
|
|
35
|
+
page cannot forget them. `x-default` points at the unprefixed URL.
|
|
36
|
+
Suppressed for noindex pages and whenever a caller passes an explicit
|
|
37
|
+
canonical, which the path-derived alternates would contradict.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **`ChangeLanguage` navigates** instead of only swapping the rendered text -
|
|
42
|
+
behind an explicit `localised` prop, so the account and admin areas (which
|
|
43
|
+
are deliberately not locale-prefixed) keep switching in place rather than
|
|
44
|
+
navigating to a 404.
|
|
45
|
+
- **The canonical drops a trailing slash.** Apache serves a prerendered page
|
|
46
|
+
from a directory and 301s `/contact` to `/contact/`, so the same page used
|
|
47
|
+
to advertise whichever form the visitor happened to arrive on. Harmless
|
|
48
|
+
when it was the only path-derived tag; not harmless now that the alternates
|
|
49
|
+
come from the same value, because one page would announce two URL sets.
|
|
50
|
+
|
|
9
51
|
## [1.16.0] - 2026-08-01
|
|
10
52
|
|
|
11
53
|
### Added
|
|
@@ -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 } />
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { TFunction } from 'i18next';
|
|
3
|
+
import { ContainerLocales, LocaleButton, Notice, Field, Label, Input, Textarea } from './styles';
|
|
4
|
+
import { useGetSettings } from '@autobusal/providers/services';
|
|
5
|
+
|
|
6
|
+
export interface SeoEntry {
|
|
7
|
+
title?: string
|
|
8
|
+
description?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SeoOverrides = Record<string, SeoEntry>;
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/**
|
|
15
|
+
* The stored map, keyed by locale. A language with no entry has no
|
|
16
|
+
* override, which is what returns that language's page to its own
|
|
17
|
+
* translated template.
|
|
18
|
+
*/
|
|
19
|
+
value?: SeoOverrides
|
|
20
|
+
t: TFunction<'common'>
|
|
21
|
+
onChange: (next: SeoOverrides) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-language SEO override editor.
|
|
26
|
+
*
|
|
27
|
+
* Ferjolt Ozuni - Date: 2026-08-02
|
|
28
|
+
*
|
|
29
|
+
* These overrides used to be one title and one description served to every
|
|
30
|
+
* language. That was merely a limitation while the whole site lived at one
|
|
31
|
+
* URL per page; once /{locale}/city/tirana existed and was announced to
|
|
32
|
+
* search engines as a distinct language version it became a contradiction -
|
|
33
|
+
* and because an override REPLACES the localised template the public pages
|
|
34
|
+
* build from live route and price data, filling one in actively made
|
|
35
|
+
* multilingual SEO worse than leaving it blank.
|
|
36
|
+
*
|
|
37
|
+
* CONTROLLED, and deliberately not a form of its own. The country and city
|
|
38
|
+
* pages submit every field in one request, so an editor with its own save
|
|
39
|
+
* button would either post a partial update and wipe the rest of the record,
|
|
40
|
+
* or leave two save buttons on one screen with different meanings. The
|
|
41
|
+
* parent owns the state and the submit; this owns the editing.
|
|
42
|
+
*
|
|
43
|
+
* One language is shown at a time rather than thirteen sets of fields down
|
|
44
|
+
* the page: a brand writes copy for one or two, and a wall of empty inputs
|
|
45
|
+
* invites nobody to fill in any of them. Languages that already have copy
|
|
46
|
+
* are marked, so it is obvious which ones are done without clicking through
|
|
47
|
+
* all of them.
|
|
48
|
+
*/
|
|
49
|
+
const SeoOverride = ({ value, t, onChange }: Props): JSX.Element => {
|
|
50
|
+
const { data } = useGetSettings();
|
|
51
|
+
|
|
52
|
+
const available = data.preferences.languages.available;
|
|
53
|
+
|
|
54
|
+
const [ locale, setLocale ] = useState<string>(data.preferences.languages.default);
|
|
55
|
+
|
|
56
|
+
const overrides = value ?? {};
|
|
57
|
+
|
|
58
|
+
const current = overrides[locale] ?? {};
|
|
59
|
+
|
|
60
|
+
const update = (field: keyof SeoEntry, next: string): void => {
|
|
61
|
+
onChange({
|
|
62
|
+
...overrides,
|
|
63
|
+
[locale]: { ...current, [field]: next }
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const written = (language: string): boolean => {
|
|
68
|
+
const entry = overrides[language];
|
|
69
|
+
|
|
70
|
+
return !!(entry?.title?.trim() || entry?.description?.trim());
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<ContainerLocales>
|
|
76
|
+
{ available.map(language => (
|
|
77
|
+
<LocaleButton
|
|
78
|
+
key={ language }
|
|
79
|
+
type="button"
|
|
80
|
+
$selected={ language === locale }
|
|
81
|
+
$written={ written(language) }
|
|
82
|
+
onClick={ () => setLocale(language) }
|
|
83
|
+
>
|
|
84
|
+
{ t(`languages.${ language }`, { ns: 'common' }) }
|
|
85
|
+
</LocaleButton>
|
|
86
|
+
)) }
|
|
87
|
+
</ContainerLocales>
|
|
88
|
+
|
|
89
|
+
<Notice>{ t('seo_override.notice', { ns: 'common' }) }</Notice>
|
|
90
|
+
|
|
91
|
+
<Field>
|
|
92
|
+
<Label>{ t('seo_override.title', { ns: 'common' }) }</Label>
|
|
93
|
+
|
|
94
|
+
<Input
|
|
95
|
+
type="text"
|
|
96
|
+
maxLength={ 250 }
|
|
97
|
+
value={ current.title ?? '' }
|
|
98
|
+
onChange={ (event) => update('title', event.target.value) }
|
|
99
|
+
/>
|
|
100
|
+
</Field>
|
|
101
|
+
|
|
102
|
+
<Field>
|
|
103
|
+
<Label>{ t('seo_override.description', { ns: 'common' }) }</Label>
|
|
104
|
+
|
|
105
|
+
<Textarea
|
|
106
|
+
maxLength={ 500 }
|
|
107
|
+
value={ current.description ?? '' }
|
|
108
|
+
onChange={ (event) => update('description', event.target.value) }
|
|
109
|
+
/>
|
|
110
|
+
</Field>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export default SeoOverride;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const ContainerLocales = styled.div`
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-wrap: wrap;
|
|
6
|
+
gap: 8px;
|
|
7
|
+
margin-bottom: 12px;
|
|
8
|
+
`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One button per language.
|
|
12
|
+
*
|
|
13
|
+
* Ferjolt Ozuni - Date: 2026-08-02
|
|
14
|
+
* `$written` marks the languages that actually have copy, so it is obvious
|
|
15
|
+
* at a glance which ones are done - without it, thirteen identical buttons
|
|
16
|
+
* say nothing about where the work stands, and the only way to find out is
|
|
17
|
+
* to click every one of them.
|
|
18
|
+
*/
|
|
19
|
+
export const LocaleButton = styled.button<{ $selected: boolean, $written: boolean }>`
|
|
20
|
+
padding: 4px 12px;
|
|
21
|
+
border: none;
|
|
22
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
23
|
+
background: ${ props => (props.$selected ? props.theme.background.neutral : 'transparent') };
|
|
24
|
+
color: ${ props => (props.$written ? props.theme.font.normal : props.theme.font.faded) };
|
|
25
|
+
font-size: ${ props => props.theme.size.s };
|
|
26
|
+
font-weight: ${ props => (props.$selected ? 700 : 400) };
|
|
27
|
+
white-space: nowrap;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
|
|
30
|
+
&:hover {
|
|
31
|
+
background: ${ props => props.theme.background.neutral };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
${ props => props.$written && css`
|
|
35
|
+
&::after {
|
|
36
|
+
content: '•';
|
|
37
|
+
margin-left: 6px;
|
|
38
|
+
color: ${ props => props.theme.primary.normal };
|
|
39
|
+
}
|
|
40
|
+
` }
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
export const Notice = styled.p`
|
|
44
|
+
margin-bottom: 15px;
|
|
45
|
+
color: ${ props => props.theme.font.faded };
|
|
46
|
+
font-size: ${ props => props.theme.size.s };
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The editor's own fields, rather than Viewer rows.
|
|
51
|
+
*
|
|
52
|
+
* Ferjolt Ozuni - Date: 2026-08-02
|
|
53
|
+
* Viewer's inputs are uncontrolled (defaultValue + a react-hook-form ref),
|
|
54
|
+
* which cannot hold thirteen languages' worth of state while showing one -
|
|
55
|
+
* switching language would discard whatever had just been typed. These are
|
|
56
|
+
* controlled, and styled to match the rows they sit among.
|
|
57
|
+
*/
|
|
58
|
+
export const Field = styled.div`
|
|
59
|
+
margin-bottom: 15px;
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
export const Label = styled.label`
|
|
63
|
+
display: block;
|
|
64
|
+
margin-bottom: 5px;
|
|
65
|
+
font-weight: 600;
|
|
66
|
+
font-size: ${ props => props.theme.size.m };
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
const field = css`
|
|
70
|
+
width: 100%;
|
|
71
|
+
padding: 10px;
|
|
72
|
+
border: 1px solid ${ props => props.theme.primary.neutral };
|
|
73
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
74
|
+
background: transparent;
|
|
75
|
+
color: ${ props => props.theme.font.normal };
|
|
76
|
+
font-family: inherit;
|
|
77
|
+
font-size: ${ props => props.theme.size.m };
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
export const Input = styled.input`
|
|
81
|
+
${ field }
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
export const Textarea = styled.textarea`
|
|
85
|
+
${ field }
|
|
86
|
+
min-height: 90px;
|
|
87
|
+
resize: vertical;
|
|
88
|
+
`;
|
package/index.ts
CHANGED
|
@@ -30,6 +30,10 @@ import Report from './Report/Report';
|
|
|
30
30
|
import RouteFeature from './RouteFeature/RouteFeature';
|
|
31
31
|
import Required from './Required/Required';
|
|
32
32
|
import Road from './Road/Road';
|
|
33
|
+
import LocaleGate from './Locale/LocaleGate';
|
|
34
|
+
import SeoOverride from './SeoOverride/SeoOverride';
|
|
35
|
+
import localiseRoutes from './Locale/routes';
|
|
36
|
+
import { splitLocale, withLocale, localeFromPath } from './Locale/locale';
|
|
33
37
|
import RouteItem from './RouteItem/RouteItem';
|
|
34
38
|
import Row from './Table/Row';
|
|
35
39
|
import Table from './Table/Table';
|
|
@@ -74,6 +78,12 @@ export {
|
|
|
74
78
|
RouteFeature,
|
|
75
79
|
Required,
|
|
76
80
|
Road,
|
|
81
|
+
LocaleGate,
|
|
82
|
+
SeoOverride,
|
|
83
|
+
localiseRoutes,
|
|
84
|
+
splitLocale,
|
|
85
|
+
withLocale,
|
|
86
|
+
localeFromPath,
|
|
77
87
|
RouteItem,
|
|
78
88
|
Row,
|
|
79
89
|
Table,
|