@autobusal/common 1.26.0 → 1.27.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 +62 -0
- package/ChangeLanguage/ChangeLanguage.tsx +23 -2
- package/Locale/locale.ts +66 -0
- package/Meta.tsx +59 -4
- package/Table/Header/Header.tsx +2 -1
- package/Table/Rows/Rows.tsx +2 -1
- package/Table/rowActions.ts +22 -0
- package/index.ts +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
|
|
4
|
+
## 1.27.0
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- **`localeHref`** — the one URL a page has in a given language, once a brand
|
|
9
|
+
answers on more than one domain. A language either has a **domain of its
|
|
10
|
+
own** and lives at that domain's bare paths (`busmagus.ro/city/tirana`), or
|
|
11
|
+
it does not and keeps its `/{locale}/` prefix on the primary. Which of the
|
|
12
|
+
two is a property of the *language*, not of the page, so this is one
|
|
13
|
+
function every caller shares. The deliberate twin of obtapi's
|
|
14
|
+
`Libraries\Labels\LocaleUrl::href`. See `magus/MULTI_TLD_PLAN.md` item 4.
|
|
15
|
+
|
|
16
|
+
With no domain list — every brand today — it resolves to `origin + path`
|
|
17
|
+
and the prefix convention, exactly as before.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **`Meta`'s canonical is resolved through the domain that owns the page's
|
|
22
|
+
language**, not off `window.location.origin`. Three things follow that the
|
|
23
|
+
browser's own host cannot say: an **alias** (a `www.` host, a legacy domain
|
|
24
|
+
kept for old links) now canonicalises to the real site instead of declaring
|
|
25
|
+
itself the original; `/it/city/tirana` on the `.com` canonicalises to
|
|
26
|
+
`busmagus.it/city/tirana` once Italian has a domain, so the prefixed form
|
|
27
|
+
stops competing with the ccTLD that replaced it; and the canonical is built
|
|
28
|
+
by the same function as the hreflang set, so it is always one of its own
|
|
29
|
+
alternates — a canonical that is not is discarded by Google outright, and
|
|
30
|
+
nothing on the page would have shown it.
|
|
31
|
+
|
|
32
|
+
- **`Meta`'s hreflang alternates point at domains** where a language has one
|
|
33
|
+
and keep their prefix where it does not. The set stays whole either way,
|
|
34
|
+
which is the part that matters: an hreflang cluster is treated as
|
|
35
|
+
reciprocal only when every member declares every other. `x-default`
|
|
36
|
+
follows the **primary's** language rather than the current origin's — a
|
|
37
|
+
`.ro` build announcing itself as x-default would offer Romanian to
|
|
38
|
+
everybody a search engine could not place.
|
|
39
|
+
|
|
40
|
+
- **`ChangeLanguage` navigates across domains.** Choosing a language that has
|
|
41
|
+
its own domain leaves the site: another origin, its own bundle, its own
|
|
42
|
+
settings payload, so it cannot go through the router. The i18n change and
|
|
43
|
+
the stored preference are deliberately skipped on that path — they would
|
|
44
|
+
apply to the site being left, and storing the language you are walking away
|
|
45
|
+
from is how somebody returns to the `.com` later and finds it rendering
|
|
46
|
+
Italian at English URLs.
|
|
47
|
+
|
|
48
|
+
## 1.26.1
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **`Table` drew an empty "Options" column when a table had only
|
|
53
|
+
top-of-table controls.** `create`, `search` and `extra` all live *above* the
|
|
54
|
+
table — `Actions/Get` already returns null for each — but `Header` and
|
|
55
|
+
`Rows` decided whether to draw the options column from `actions.length > 0`,
|
|
56
|
+
which counted them. A table using the component purely for sorting, search
|
|
57
|
+
and paging therefore got an "Options" heading with an empty cell under every
|
|
58
|
+
row.
|
|
59
|
+
|
|
60
|
+
Invisible on the admin screens, where every table has row actions anyway;
|
|
61
|
+
visible the moment the public bus-company directory used it. The row/top
|
|
62
|
+
split is one shared list now (`Table/rowActions.ts`) rather than a condition
|
|
63
|
+
repeated in three files that could drift.
|
|
64
|
+
|
|
3
65
|
## 1.26.0
|
|
4
66
|
|
|
5
67
|
### Added
|
|
@@ -3,7 +3,7 @@ import { TFunction, i18n } from 'i18next';
|
|
|
3
3
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
4
4
|
import { useOutside } from '@autobusal/hooks';
|
|
5
5
|
import Flag from './Flag';
|
|
6
|
-
import { withLocale } from '../Locale/locale';
|
|
6
|
+
import { withLocale, localeHref } from '../Locale/locale';
|
|
7
7
|
import { Container, Title, ContainerButtons, ButtonChange } from './styles';
|
|
8
8
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
9
9
|
|
|
@@ -55,6 +55,27 @@ const ChangeLanguage = ({ type, t, i18n, localised = false, onClose }: Props): J
|
|
|
55
55
|
* language in place and stay put.
|
|
56
56
|
*/
|
|
57
57
|
const onChange = (language: string): void => {
|
|
58
|
+
const tail = `${ location.search }${ location.hash }`;
|
|
59
|
+
|
|
60
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-06
|
|
61
|
+
// A language with a DOMAIN of its own lives on that domain, so choosing
|
|
62
|
+
// it leaves this site. That cannot go through the router - another
|
|
63
|
+
// origin, its own bundle, its own settings payload - so it is a real
|
|
64
|
+
// navigation, and the two state changes below are deliberately skipped:
|
|
65
|
+
// they would apply to the site being left rather than the one being
|
|
66
|
+
// opened, and storing the language we are walking away from is how
|
|
67
|
+
// somebody returns to the .com later and finds it rendering Italian at
|
|
68
|
+
// English URLs.
|
|
69
|
+
if (localised) {
|
|
70
|
+
const href = localeHref(location.pathname, language, fallback, available, data.domains ?? [], window.location.origin);
|
|
71
|
+
|
|
72
|
+
if (!href.startsWith(window.location.origin)) {
|
|
73
|
+
window.location.assign(`${ href }${ tail }`);
|
|
74
|
+
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
58
79
|
i18n.changeLanguage(language);
|
|
59
80
|
localStorage.setItem('language', language);
|
|
60
81
|
|
|
@@ -62,7 +83,7 @@ const ChangeLanguage = ({ type, t, i18n, localised = false, onClose }: Props): J
|
|
|
62
83
|
const to = withLocale(location.pathname, language, fallback, available);
|
|
63
84
|
|
|
64
85
|
if (to !== location.pathname) {
|
|
65
|
-
navigate(`${ to }${
|
|
86
|
+
navigate(`${ to }${ tail }`);
|
|
66
87
|
}
|
|
67
88
|
}
|
|
68
89
|
|
package/Locale/locale.ts
CHANGED
|
@@ -58,6 +58,72 @@ export const withLocale = (pathname: string, locale: string, defaultLocale: stri
|
|
|
58
58
|
return clean === '/' ? `/${ locale }` : `/${ locale }${ clean }`;
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* One origin a brand answers on, and the language it serves there.
|
|
63
|
+
*
|
|
64
|
+
* Ferjolt Ozuni - Date: 2026-08-06
|
|
65
|
+
* Mirrors obtapi's Settings\ViewController::domains(). `locale` may be null:
|
|
66
|
+
* an alias (a www. host, a legacy domain kept alive for old links) answers
|
|
67
|
+
* without claiming a language, so nothing points hreflang at it.
|
|
68
|
+
*/
|
|
69
|
+
export interface LocaleDomain {
|
|
70
|
+
domain: string
|
|
71
|
+
locale?: string | null
|
|
72
|
+
primary?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The ONE URL a page has in a given language.
|
|
77
|
+
*
|
|
78
|
+
* Ferjolt Ozuni - Date: 2026-08-06 - see magus/MULTI_TLD_PLAN.md item 4.
|
|
79
|
+
*
|
|
80
|
+
* A language reaches its own URL one of two ways, and which one is a
|
|
81
|
+
* property of the language rather than of the page:
|
|
82
|
+
*
|
|
83
|
+
* - it has a DOMAIN of its own, and lives at that domain's bare paths -
|
|
84
|
+
* busmagus.ro/city/tirana, not busmagus.com/ro/city/tirana;
|
|
85
|
+
* - it does not, and lives under a /{locale}/ prefix on the primary.
|
|
86
|
+
*
|
|
87
|
+
* Both forms have to be derivable from anywhere, because the canonical, the
|
|
88
|
+
* hreflang set and the language switcher must agree to the character. They
|
|
89
|
+
* are the same claim about the same page said three times, and a canonical
|
|
90
|
+
* that is not itself in its own hreflang set is discarded outright.
|
|
91
|
+
*
|
|
92
|
+
* `fallbackOrigin` is what to call "here" when the brand has no domain list
|
|
93
|
+
* at all - an older API, or a payload that has not arrived. It resolves to
|
|
94
|
+
* the previous single-origin behaviour exactly, which is why this is safe to
|
|
95
|
+
* put on every page before any brand has a second domain.
|
|
96
|
+
*/
|
|
97
|
+
export const localeHref = (
|
|
98
|
+
pathname: string,
|
|
99
|
+
locale: string,
|
|
100
|
+
defaultLocale: string,
|
|
101
|
+
available: string[],
|
|
102
|
+
domains: LocaleDomain[],
|
|
103
|
+
fallbackOrigin: string
|
|
104
|
+
): string => {
|
|
105
|
+
const { path } = splitLocale(pathname, available);
|
|
106
|
+
|
|
107
|
+
const clean = path === '' ? '/' : path;
|
|
108
|
+
|
|
109
|
+
const owner = domains.find(item => item.locale === locale);
|
|
110
|
+
|
|
111
|
+
if (owner) {
|
|
112
|
+
return owner.domain + clean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const primary = domains.find(item => item.primary);
|
|
116
|
+
|
|
117
|
+
// Which language is unprefixed on the primary - NOT `defaultLocale`, which
|
|
118
|
+
// on a ccTLD is that ccTLD's own language (obtapi answers the per-origin
|
|
119
|
+
// question there). Reading it from the primary row is what stops the .ro
|
|
120
|
+
// build announcing English as busmagus.com/en/... when English is the
|
|
121
|
+
// thing that lives at busmagus.com/... itself.
|
|
122
|
+
const unprefixed = primary?.locale ?? defaultLocale;
|
|
123
|
+
|
|
124
|
+
return (primary?.domain ?? fallbackOrigin) + withLocale(clean, locale, unprefixed, available);
|
|
125
|
+
};
|
|
126
|
+
|
|
61
127
|
/**
|
|
62
128
|
* Which language a URL is asking for, falling back to the stored preference
|
|
63
129
|
* and then the brand default.
|
package/Meta.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { useLocation } from 'react-router-dom';
|
|
3
|
-
import { splitLocale,
|
|
3
|
+
import { splitLocale, localeHref } from './Locale/locale';
|
|
4
4
|
import { pageview } from '@autobusal/providers/Setup/analytics';
|
|
5
5
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
6
6
|
|
|
@@ -155,10 +155,45 @@ const normalisePath = (pathname: string): string => (
|
|
|
155
155
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.replace(/\/+$/, '') : pathname
|
|
156
156
|
);
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-06
|
|
160
|
+
* Resolved through the domain that OWNS this page's language rather than off
|
|
161
|
+
* whichever host the visitor happens to be on - see localeHref and
|
|
162
|
+
* magus/MULTI_TLD_PLAN.md item 4.
|
|
163
|
+
*
|
|
164
|
+
* Three things fall out of that which `window.location.origin` cannot say:
|
|
165
|
+
*
|
|
166
|
+
* - an ALIAS (a www. host, a legacy domain kept alive for old links) now
|
|
167
|
+
* canonicalises to the real site instead of declaring itself the
|
|
168
|
+
* original, which is the whole job of a canonical and the one case where
|
|
169
|
+
* taking it from the browser is actively wrong;
|
|
170
|
+
* - /it/city/tirana served from the .com canonicalises to
|
|
171
|
+
* busmagus.it/city/tirana once Italian has a domain, so the prefixed form
|
|
172
|
+
* stops competing with the ccTLD that replaced it;
|
|
173
|
+
* - the canonical is built by the same function as the hreflang set, so it
|
|
174
|
+
* is always one of its own alternates. A canonical that is not is
|
|
175
|
+
* discarded by Google outright, and nothing on the page would show it.
|
|
176
|
+
*
|
|
177
|
+
* With no domain list this is `origin + path`, exactly as before.
|
|
178
|
+
*/
|
|
158
179
|
const useCanonicalUrl = (explicit?: string): string => {
|
|
159
180
|
const location = useLocation();
|
|
160
181
|
|
|
161
|
-
|
|
182
|
+
const { data } = useGetSettings();
|
|
183
|
+
|
|
184
|
+
if (explicit) {
|
|
185
|
+
return explicit;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const available = data?.preferences?.languages?.available ?? [];
|
|
189
|
+
|
|
190
|
+
const fallback = data?.preferences?.languages?.default ?? 'en';
|
|
191
|
+
|
|
192
|
+
const path = normalisePath(location.pathname);
|
|
193
|
+
|
|
194
|
+
const { locale } = splitLocale(path, available);
|
|
195
|
+
|
|
196
|
+
return localeHref(path, locale ?? fallback, fallback, available, data?.domains ?? [], window.location.origin);
|
|
162
197
|
};
|
|
163
198
|
|
|
164
199
|
// A single-page app never reloads, so GTM's page-load trigger fires exactly
|
|
@@ -207,6 +242,19 @@ const usePageviewTracking = (path: string, title: string): void => {
|
|
|
207
242
|
* Suppressed for noindex pages: they have nothing to offer alternates for,
|
|
208
243
|
* and telling a crawler about language variants of a page it has just been
|
|
209
244
|
* asked not to index is noise at best.
|
|
245
|
+
*
|
|
246
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-06
|
|
247
|
+
* Each language now resolves to the DOMAIN that owns it where it has one,
|
|
248
|
+
* and keeps its /{locale}/ prefix on the primary where it does not - see
|
|
249
|
+
* localeHref. The set stays whole either way, which is the part that
|
|
250
|
+
* matters: Google treats an hreflang cluster as reciprocal only when every
|
|
251
|
+
* member declares every other, so a page cannot list "the domains" and leave
|
|
252
|
+
* out the languages that have none.
|
|
253
|
+
*
|
|
254
|
+
* x-default follows the PRIMARY's own language rather than this origin's.
|
|
255
|
+
* The .com is the answer when there is no signal about the visitor; a .ro
|
|
256
|
+
* build announcing itself as x-default would be offering Romanian to
|
|
257
|
+
* everyone the search engine could not place.
|
|
210
258
|
*/
|
|
211
259
|
const useAlternates = (noIndex: boolean, explicitUrl?: string): { locale: string, href: string }[] => {
|
|
212
260
|
const location = useLocation();
|
|
@@ -226,13 +274,20 @@ const useAlternates = (noIndex: boolean, explicitUrl?: string): { locale: string
|
|
|
226
274
|
|
|
227
275
|
const origin = window.location.origin;
|
|
228
276
|
|
|
277
|
+
const domains = data?.domains ?? [];
|
|
278
|
+
|
|
229
279
|
const { path } = splitLocale(normalisePath(location.pathname), available);
|
|
230
280
|
|
|
281
|
+
// The primary's own language, so x-default lands on the .com. Falls back
|
|
282
|
+
// to this origin's default when there is no domain list at all, which is
|
|
283
|
+
// the single-domain case and exactly the previous behaviour.
|
|
284
|
+
const neutral = domains.find(item => item.primary)?.locale ?? fallback;
|
|
285
|
+
|
|
231
286
|
return [
|
|
232
|
-
{ locale: 'x-default', href:
|
|
287
|
+
{ locale: 'x-default', href: localeHref(path, neutral, fallback, available, domains, origin) },
|
|
233
288
|
...available.map(locale => ({
|
|
234
289
|
locale,
|
|
235
|
-
href:
|
|
290
|
+
href: localeHref(path, locale, fallback, available, domains, origin)
|
|
236
291
|
}))
|
|
237
292
|
];
|
|
238
293
|
};
|
package/Table/Header/Header.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import Sort from './Sort';
|
|
3
3
|
import { getUrl } from '../browseUrl';
|
|
4
|
+
import { rowActions } from '../rowActions';
|
|
4
5
|
import { Container, Td, Column, Text } from './styles';
|
|
5
6
|
import { ColumnData, SortingData } from '../types';
|
|
6
7
|
|
|
@@ -31,7 +32,7 @@ const Header = ({ url, data, sorting, t, actions }: Props): JSX.Element => {
|
|
|
31
32
|
<tr>
|
|
32
33
|
{ columns }
|
|
33
34
|
|
|
34
|
-
{ (actions
|
|
35
|
+
{ rowActions(actions).length > 0 && (
|
|
35
36
|
<Td $width={ 10 }>
|
|
36
37
|
<Column>
|
|
37
38
|
<Text>{ t('table.options', { ns: 'common' }) }</Text>
|
package/Table/Rows/Rows.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { NavigateFunction } from 'react-router-dom';
|
|
|
3
3
|
import Loading from './Loading';
|
|
4
4
|
import NotFound from './NotFound';
|
|
5
5
|
import Actions from '../Actions/Actions';
|
|
6
|
+
import { rowActions } from '../rowActions';
|
|
6
7
|
import { Tbody, Tr } from './styles';
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
@@ -39,7 +40,7 @@ const Rows = ({ url, urlWith, data, loading, fetching, colLength, t, actions, ha
|
|
|
39
40
|
<Tr key={ index } $viewable={ isViewable } onClick={ () => onClick(item.props.id) }>
|
|
40
41
|
{ item }
|
|
41
42
|
|
|
42
|
-
{ (actions
|
|
43
|
+
{ rowActions(actions).length > 0 && (
|
|
43
44
|
<Actions
|
|
44
45
|
id={ item.props.id }
|
|
45
46
|
url={ url }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a table's actions belong to a ROW rather than to the top of the
|
|
3
|
+
* table.
|
|
4
|
+
*
|
|
5
|
+
* Ferjolt Ozuni - Date: 2026-08-06
|
|
6
|
+
*
|
|
7
|
+
* `create`, `search` and `extra` are all controls that live ABOVE the table -
|
|
8
|
+
* Top renders them and Actions/Get already returns null for each. But Header
|
|
9
|
+
* and Rows both decided whether to draw the options column from
|
|
10
|
+
* `actions.length > 0`, counting those three, so a table with only
|
|
11
|
+
* top-of-table controls got an "Options" heading with an empty cell under
|
|
12
|
+
* every row. Harmless on the admin screens, where every table has row actions
|
|
13
|
+
* anyway - visible the moment a PUBLIC page used the component for its
|
|
14
|
+
* sorting and paging alone.
|
|
15
|
+
*
|
|
16
|
+
* One list, shared by the three places that need it, so it cannot drift.
|
|
17
|
+
*/
|
|
18
|
+
export const TOP_ACTIONS = ['create', 'search', 'extra'];
|
|
19
|
+
|
|
20
|
+
export const rowActions = (actions?: string[]): string[] => (
|
|
21
|
+
(actions ?? []).filter(action => !TOP_ACTIONS.includes(action))
|
|
22
|
+
);
|
package/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ import LocaleGate from './Locale/LocaleGate';
|
|
|
36
36
|
import SeoOverride from './SeoOverride/SeoOverride';
|
|
37
37
|
import HandoffBanner from './HandoffBanner/HandoffBanner';
|
|
38
38
|
import localiseRoutes from './Locale/routes';
|
|
39
|
-
import { splitLocale, withLocale, localeFromPath } from './Locale/locale';
|
|
39
|
+
import { splitLocale, withLocale, localeFromPath, localeHref } from './Locale/locale';
|
|
40
40
|
import RouteItem from './RouteItem/RouteItem';
|
|
41
41
|
import Row from './Table/Row';
|
|
42
42
|
import Table from './Table/Table';
|
|
@@ -91,10 +91,13 @@ export {
|
|
|
91
91
|
splitLocale,
|
|
92
92
|
withLocale,
|
|
93
93
|
localeFromPath,
|
|
94
|
+
localeHref,
|
|
94
95
|
RouteItem,
|
|
95
96
|
Row,
|
|
96
97
|
Table,
|
|
97
98
|
UnderConstruction,
|
|
98
99
|
useUnderConstruction,
|
|
99
100
|
Viewer
|
|
100
|
-
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type { LocaleDomain } from './Locale/locale';
|