@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 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;
@@ -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 } />
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"