Put locale in compliance portal URLs
Path-segment locales make each language crawlable with self canonical and hreflang, while identity.locale persists an explicit choice without client storage or cookie banners. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="{{if .HtmlLang}}{{.HtmlLang}}{{else}}en{{end}}">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -41,6 +41,9 @@
|
||||
<meta property="og:description" content="{{.Description}}">
|
||||
<meta property="og:url" content="{{.OGURL}}">
|
||||
<meta property="og:type" content="website">
|
||||
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
|
||||
{{range .Hreflang}}<link rel="alternate" hreflang="{{.Lang}}" href="{{.Href}}">
|
||||
{{end}}
|
||||
</head>
|
||||
|
||||
<body class="text-sand-12 bg-sand-1">
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Vorherige Seite",
|
||||
"next": "Nächste Seite"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Sprache",
|
||||
"updateFailed": "Sprache konnte nicht aktualisiert werden",
|
||||
"mismatch": {
|
||||
"message": "Sie sehen diese Seite auf {{language}}.",
|
||||
"switchToMine": "Zu {{language}} wechseln",
|
||||
"useThis": "{{language}} als meine Sprache verwenden",
|
||||
"dismiss": "Schließen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
"signOut": "Sign out",
|
||||
"signOutFailed": "Cannot sign out"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"error": "Something went wrong",
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Página anterior",
|
||||
"next": "Página siguiente"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Idioma",
|
||||
"updateFailed": "No se pudo actualizar el idioma",
|
||||
"mismatch": {
|
||||
"message": "Estás viendo esta página en {{language}}.",
|
||||
"switchToMine": "Cambiar a {{language}}",
|
||||
"useThis": "Usar {{language}} como mi idioma",
|
||||
"dismiss": "Descartar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"error": "Une erreur est survenue",
|
||||
"previousPage": "Page pr\u00e9c\u00e9dente",
|
||||
"previousPage": "Page précédente",
|
||||
"nextPage": "Page suivante",
|
||||
"pageOf": "Page {{current}} sur {{total}}",
|
||||
"zoomOut": "Zoom arri\u00e8re",
|
||||
"zoomOut": "Zoom arrière",
|
||||
"zoomIn": "Zoom avant"
|
||||
},
|
||||
"auth": {
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Page précédente",
|
||||
"next": "Page suivante"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Langue",
|
||||
"updateFailed": "Impossible de mettre à jour la langue",
|
||||
"mismatch": {
|
||||
"message": "Vous consultez cette page en {{language}}.",
|
||||
"switchToMine": "Passer en {{language}}",
|
||||
"useThis": "Utiliser {{language}} comme ma langue",
|
||||
"dismiss": "Ignorer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Halaman sebelumnya",
|
||||
"next": "Halaman berikutnya"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Pagina precedente",
|
||||
"next": "Pagina successiva"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "前のページ",
|
||||
"next": "次のページ"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "이전 페이지",
|
||||
"next": "다음 페이지"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Poprzednia strona",
|
||||
"next": "Następna strona"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Página anterior",
|
||||
"next": "Página seguinte"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Önceki sayfa",
|
||||
"next": "Sonraki sayfa"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "Попередня сторінка",
|
||||
"next": "Наступна сторінка"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"pagination": {
|
||||
"previous": "上一页",
|
||||
"next": "下一页"
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"updateFailed": "Couldn't update language",
|
||||
"mismatch": {
|
||||
"message": "You're viewing this page in {{language}}.",
|
||||
"switchToMine": "Switch to {{language}}",
|
||||
"useThis": "Use {{language}} as my language",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
apps/compliance-portal/src/components/LocaleMismatchCallout.tsx
Normal file
119
apps/compliance-portal/src/components/LocaleMismatchCallout.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { GlobeIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { Callout } from "@probo/ui/src/v2/Callout/Callout";
|
||||
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import {
|
||||
isUrlLocale,
|
||||
URL_LOCALE_LABELS,
|
||||
} from "#/lib/i18n/locale";
|
||||
import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
|
||||
import { useLocale } from "#/lib/i18n/useLocale";
|
||||
import { useUpdateLocale } from "#/lib/i18n/useUpdateLocale";
|
||||
|
||||
import type { LocaleMismatchCallout_identity$key } from "./__generated__/LocaleMismatchCallout_identity.graphql";
|
||||
|
||||
const localeMismatchCalloutFragment = graphql`
|
||||
fragment LocaleMismatchCallout_identity on Identity {
|
||||
locale
|
||||
}
|
||||
`;
|
||||
|
||||
interface LocaleMismatchCalloutProps {
|
||||
identityKey: LocaleMismatchCallout_identity$key;
|
||||
}
|
||||
|
||||
// Soft notice when the URL locale differs from the signed-in identity preference.
|
||||
// Dismissed state is React-only (no localStorage/cookies).
|
||||
export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProps) {
|
||||
const { t } = useTranslation();
|
||||
const identity = useFragment(localeMismatchCalloutFragment, identityKey);
|
||||
const urlLocale = useLocale();
|
||||
const [changeLocale, isChanging] = useChangeLocale();
|
||||
const [updateLocale, isUpdating] = useUpdateLocale();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const savedLocale = isUrlLocale(identity.locale) ? identity.locale : null;
|
||||
|
||||
if (dismissed || savedLocale == null || savedLocale === urlLocale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlLabel = URL_LOCALE_LABELS[urlLocale];
|
||||
const savedLabel = URL_LOCALE_LABELS[savedLocale];
|
||||
const busy = isChanging || isUpdating;
|
||||
|
||||
const switchToSaved = () => {
|
||||
void changeLocale(savedLocale, { persist: false });
|
||||
};
|
||||
|
||||
const adoptUrlLocale = () => {
|
||||
void updateLocale(urlLocale).then(() => setDismissed(true));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-sand-6 bg-sand-2 px-4 py-3">
|
||||
<Callout size={1} variant="soft" color="gold" icon={<GlobeIcon weight="fill" />}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="min-w-0 flex-1 text-2 text-sand-12">
|
||||
{t("locale.mismatch.message", { language: urlLabel })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size={1}
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
disabled={busy}
|
||||
onClick={switchToSaved}
|
||||
>
|
||||
{t("locale.mismatch.switchToMine", { language: savedLabel })}
|
||||
</Button>
|
||||
<Button
|
||||
size={1}
|
||||
variant="solid"
|
||||
color="neutral"
|
||||
highContrast
|
||||
disabled={busy}
|
||||
onClick={adoptUrlLocale}
|
||||
>
|
||||
{t("locale.mismatch.useThis", { language: urlLabel })}
|
||||
</Button>
|
||||
<IconButton
|
||||
size={1}
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("locale.mismatch.dismiss")}
|
||||
disabled={busy}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { Link } from "react-router";
|
||||
|
||||
import { ComplianceArticleItem } from "#/components/ComplianceArticleItem/ComplianceArticleItem";
|
||||
import { formatRelativeTime } from "#/lib/datetime/relativeTime";
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
|
||||
import type { MailingListUpdateListItem_update$key } from "./__generated__/MailingListUpdateListItem_update.graphql";
|
||||
|
||||
@@ -44,10 +45,11 @@ interface MailingListUpdateListItemProps {
|
||||
// no category, so we show the title and relative date with a generic icon.
|
||||
export function MailingListUpdateListItem({ updateKey }: MailingListUpdateListItemProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const localizedPath = useLocalizedPath();
|
||||
const update = useFragment(mailingListUpdateListItemFragment, updateKey);
|
||||
|
||||
return (
|
||||
<Link to={`/updates/${update.id}`} className="block transition-colors hover:bg-sand-a2">
|
||||
<Link to={localizedPath(`/updates/${update.id}`)} className="block transition-colors hover:bg-sand-a2">
|
||||
<ComplianceArticleItem
|
||||
icon={<NewspaperIcon weight="light" />}
|
||||
title={update.title}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { InlineErrorCard } from "#/components/errors/InlineErrorCard";
|
||||
import { HomeSection } from "#/components/HomeSection/HomeSection";
|
||||
import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
|
||||
import { dotPatternStyle } from "#/components/MediaTile/variants";
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
|
||||
import type { RecentUpdatesSection_compliancePortal$key } from "./__generated__/RecentUpdatesSection_compliancePortal.graphql";
|
||||
|
||||
@@ -71,6 +72,7 @@ export function RecentUpdatesSection({ compliancePortalKey }: RecentUpdatesSecti
|
||||
|
||||
function RecentUpdatesSectionContent({ compliancePortalKey }: RecentUpdatesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const localizedPath = useLocalizedPath();
|
||||
const data = useFragment(recentUpdatesSectionFragment, compliancePortalKey);
|
||||
const updates = data.updates.edges.map(edge => edge.node);
|
||||
|
||||
@@ -82,7 +84,7 @@ function RecentUpdatesSectionContent({ compliancePortalKey }: RecentUpdatesSecti
|
||||
<HomeSection
|
||||
title={t("home.sections.recentUpdates")}
|
||||
action={(
|
||||
<Link to="/updates" variant="ghost" color="neutral" size={2}>
|
||||
<Link to={localizedPath("/updates")} variant="ghost" color="neutral" size={2}>
|
||||
{t("home.recentUpdates.viewAll")}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { GlobeIcon } from "@phosphor-icons/react";
|
||||
import { Select } from "@probo/ui/src/v2/Select/Select";
|
||||
import { SelectItem } from "@probo/ui/src/v2/Select/SelectItem";
|
||||
import { SelectPopup } from "@probo/ui/src/v2/Select/SelectPopup";
|
||||
import { SelectTrigger } from "@probo/ui/src/v2/Select/SelectTrigger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
URL_LOCALE_LABELS,
|
||||
URL_LOCALES,
|
||||
type UrlLocale,
|
||||
} from "#/lib/i18n/locale";
|
||||
import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
|
||||
import { useLocale } from "#/lib/i18n/useLocale";
|
||||
|
||||
interface LocaleSelectProps {
|
||||
// Persist the choice on the signed-in identity when true.
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
// Compact locale control for the top bar (guest and mobile). Uses the v2 Select
|
||||
// (Figma Select / ghost + globe) rather than a custom dropdown.
|
||||
export function LocaleSelect({ persist = false }: LocaleSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useLocale();
|
||||
const [changeLocale, isChanging] = useChangeLocale();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={locale}
|
||||
onValueChange={(value: UrlLocale | null) => {
|
||||
if (value == null || value === locale) {
|
||||
return;
|
||||
}
|
||||
void changeLocale(value, { persist });
|
||||
}}
|
||||
disabled={isChanging}
|
||||
>
|
||||
<SelectTrigger
|
||||
size={1}
|
||||
variant="ghost"
|
||||
aria-label={t("locale.label")}
|
||||
className="w-auto min-w-0"
|
||||
>
|
||||
{(value: UrlLocale | null) => (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<GlobeIcon className="size-3.5 shrink-0" aria-hidden />
|
||||
{value ? URL_LOCALE_LABELS[value] : null}
|
||||
</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{URL_LOCALES.map(code => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{URL_LOCALE_LABELS[code]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -28,8 +28,10 @@ import { graphql, useFragment } from "react-relay";
|
||||
import { Link as RouterLink, useLocation } from "react-router";
|
||||
|
||||
import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
|
||||
import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql";
|
||||
import { LocaleSelect } from "./LocaleSelect";
|
||||
import { TOP_BAR_NAV_ITEMS } from "./navItems";
|
||||
import { TopBarMobileNav } from "./TopBarMobileNav";
|
||||
import { TopBarUserMenu } from "./TopBarUserMenu";
|
||||
@@ -61,17 +63,19 @@ export function TopBar({ queryKey }: TopBarProps) {
|
||||
const data = useFragment(topBarFragment, queryKey);
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
const localizedPath = useLocalizedPath();
|
||||
|
||||
const { currentCompliancePortal } = data;
|
||||
const title = currentCompliancePortal.title;
|
||||
const logoUrl = currentCompliancePortal.themedLogoUrl ?? undefined;
|
||||
|
||||
const slots = topBar();
|
||||
const homePath = localizedPath("/");
|
||||
|
||||
return (
|
||||
<header className={slots.bar()}>
|
||||
<div className={slots.inner()}>
|
||||
<RouterLink to="/" className={slots.brand()}>
|
||||
<RouterLink to={homePath} className={slots.brand()}>
|
||||
<Avatar
|
||||
size={1}
|
||||
variant="soft"
|
||||
@@ -92,31 +96,37 @@ export function TopBar({ queryKey }: TopBarProps) {
|
||||
<div className={slots.spacer()} />
|
||||
|
||||
<nav className={slots.nav()}>
|
||||
{TOP_BAR_NAV_ITEMS.map(item => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size={2}
|
||||
active={isActive(pathname, item.to)}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
))}
|
||||
{TOP_BAR_NAV_ITEMS.map((item) => {
|
||||
const to = localizedPath(item.to);
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={to}
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size={2}
|
||||
active={isActive(pathname, to)}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{data.viewer == null
|
||||
? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="neutral"
|
||||
highContrast
|
||||
iconStart={<LockSimpleIcon />}
|
||||
onClick={() => {
|
||||
redirectToInitiate(buildRequestAllContinueUrl());
|
||||
}}
|
||||
>
|
||||
{t("topBar.getAccess")}
|
||||
</Button>
|
||||
<>
|
||||
<LocaleSelect />
|
||||
<Button
|
||||
variant="solid"
|
||||
color="neutral"
|
||||
highContrast
|
||||
iconStart={<LockSimpleIcon />}
|
||||
onClick={() => {
|
||||
redirectToInitiate(buildRequestAllContinueUrl());
|
||||
}}
|
||||
>
|
||||
{t("topBar.getAccess")}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: <TopBarUserMenu identityKey={data.viewer} />}
|
||||
</nav>
|
||||
|
||||
@@ -45,9 +45,11 @@ import { useLocation } from "react-router";
|
||||
|
||||
import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
|
||||
import { useSignOut } from "#/lib/auth/useSignOut";
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
||||
|
||||
import type { TopBarMobileNav_identity$key } from "./__generated__/TopBarMobileNav_identity.graphql";
|
||||
import { LocaleSelect } from "./LocaleSelect";
|
||||
import { TOP_BAR_NAV_ITEMS } from "./navItems";
|
||||
import { topBar } from "./variants";
|
||||
|
||||
@@ -75,6 +77,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
||||
const [signOut, isSigningOut] = useSignOut();
|
||||
const [open, setOpen] = useState(false);
|
||||
const identity = useFragment(topBarMobileNavFragment, identityKey);
|
||||
const localizedPath = useLocalizedPath();
|
||||
|
||||
const barSlots = topBar();
|
||||
|
||||
@@ -119,24 +122,30 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
||||
|
||||
<DrawerBody>
|
||||
<nav className="contents">
|
||||
{TOP_BAR_NAV_ITEMS.map(item => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size={3}
|
||||
active={isActive(pathname, item.to)}
|
||||
className="w-full justify-start"
|
||||
onClick={close}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
))}
|
||||
{TOP_BAR_NAV_ITEMS.map((item) => {
|
||||
const to = localizedPath(item.to);
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={to}
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size={3}
|
||||
active={isActive(pathname, to)}
|
||||
className="w-full justify-start"
|
||||
onClick={close}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</DrawerBody>
|
||||
|
||||
<DrawerFooter>
|
||||
<div className="w-full pb-2">
|
||||
<LocaleSelect persist={identity != null} />
|
||||
</div>
|
||||
{identity == null
|
||||
? (
|
||||
<Button
|
||||
|
||||
@@ -18,19 +18,37 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { BellIcon, BellRingingIcon, CaretDownIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
BellIcon,
|
||||
BellRingingIcon,
|
||||
CaretDownIcon,
|
||||
GlobeIcon,
|
||||
SignOutIcon,
|
||||
UserIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
|
||||
import { Dropdown } from "@probo/ui/src/v2/Dropdown/Dropdown";
|
||||
import { DropdownGroup } from "@probo/ui/src/v2/Dropdown/DropdownGroup";
|
||||
import { DropdownItem } from "@probo/ui/src/v2/Dropdown/DropdownItem";
|
||||
import { DropdownPopup } from "@probo/ui/src/v2/Dropdown/DropdownPopup";
|
||||
import { DropdownRadioGroup } from "@probo/ui/src/v2/Dropdown/DropdownRadioGroup";
|
||||
import { DropdownRadioItem } from "@probo/ui/src/v2/Dropdown/DropdownRadioItem";
|
||||
import { DropdownSeparator } from "@probo/ui/src/v2/Dropdown/DropdownSeparator";
|
||||
import { DropdownSubmenu } from "@probo/ui/src/v2/Dropdown/DropdownSubmenu";
|
||||
import { DropdownSubmenuTrigger } from "@probo/ui/src/v2/Dropdown/DropdownSubmenuTrigger";
|
||||
import { DropdownTrigger } from "@probo/ui/src/v2/Dropdown/DropdownTrigger";
|
||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useSignOut } from "#/lib/auth/useSignOut";
|
||||
import {
|
||||
URL_LOCALE_LABELS,
|
||||
URL_LOCALES,
|
||||
type UrlLocale,
|
||||
} from "#/lib/i18n/locale";
|
||||
import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
|
||||
import { useLocale } from "#/lib/i18n/useLocale";
|
||||
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
||||
|
||||
import type { TopBarUserMenu_identity$key } from "./__generated__/TopBarUserMenu_identity.graphql";
|
||||
@@ -40,6 +58,7 @@ const topBarUserMenuFragment = graphql`
|
||||
fragment TopBarUserMenu_identity on Identity {
|
||||
fullName
|
||||
email
|
||||
locale
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -52,6 +71,8 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
||||
const identity = useFragment(topBarUserMenuFragment, identityKey);
|
||||
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
|
||||
const [signOut, isSigningOut] = useSignOut();
|
||||
const locale = useLocale();
|
||||
const [changeLocale, isChangingLocale] = useChangeLocale();
|
||||
|
||||
// New users may not have set a full name yet; fall back to the email.
|
||||
const displayName = identity.fullName.trim() || identity.email;
|
||||
@@ -103,6 +124,25 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
||||
>
|
||||
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
||||
</DropdownItem>
|
||||
<DropdownSubmenu>
|
||||
<DropdownSubmenuTrigger iconStart={<GlobeIcon />}>
|
||||
{t("locale.label")}
|
||||
</DropdownSubmenuTrigger>
|
||||
<DropdownPopup side="left" align="start">
|
||||
<DropdownRadioGroup
|
||||
value={locale}
|
||||
onValueChange={(value: string) => {
|
||||
void changeLocale(value as UrlLocale, { persist: true });
|
||||
}}
|
||||
>
|
||||
{URL_LOCALES.map(code => (
|
||||
<DropdownRadioItem key={code} value={code} disabled={isChangingLocale}>
|
||||
{URL_LOCALE_LABELS[code]}
|
||||
</DropdownRadioItem>
|
||||
))}
|
||||
</DropdownRadioGroup>
|
||||
</DropdownPopup>
|
||||
</DropdownSubmenu>
|
||||
<DropdownSeparator />
|
||||
<DropdownItem
|
||||
color="error"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Link } from "@probo/ui/src/v2/Button/Link";
|
||||
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
import { NotFoundError } from "#/lib/relay/errors";
|
||||
|
||||
interface ErrorContent {
|
||||
@@ -64,6 +65,7 @@ interface GlobalErrorProps {
|
||||
// actions. Used by the route boundaries (root + page).
|
||||
export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorProps) {
|
||||
const { t } = useTranslation();
|
||||
const localizedPath = useLocalizedPath();
|
||||
const { code, titleKey, descriptionKey } = resolveContent(error);
|
||||
|
||||
return (
|
||||
@@ -74,7 +76,7 @@ export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorPro
|
||||
description={t(descriptionKey)}
|
||||
actions={(
|
||||
<>
|
||||
<Link to="/" variant="solid" color="neutral" highContrast size={2}>
|
||||
<Link to={localizedPath("/")} variant="solid" color="neutral" highContrast size={2}>
|
||||
{t("errors.actions.backToCompliancePortal")}
|
||||
</Link>
|
||||
{onRetry && (
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay";
|
||||
|
||||
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||
import { localizedPath, resolveUrlLocale, type UrlLocale } from "#/lib/i18n/locale";
|
||||
|
||||
// Markers appended to a post-auth `continue` URL so the portal fires the pending
|
||||
// "request access" mutation once the user lands back authenticated. `request-all`
|
||||
@@ -41,7 +42,8 @@ export const SUBSCRIBE_PARAM = "subscribe";
|
||||
// portal home, so a crafted `?continue=` can never bounce the user off-site.
|
||||
export function getSafeContinueUrl(param: string | null | undefined): string {
|
||||
const prefix = getPathPrefix();
|
||||
const fallback = window.location.origin + (prefix || "/");
|
||||
const localeHome = localizedPath(resolveUrlLocale(), "/");
|
||||
const fallback = window.location.origin + (prefix || "") + localeHome;
|
||||
|
||||
if (!param) {
|
||||
return fallback;
|
||||
@@ -49,10 +51,10 @@ export function getSafeContinueUrl(param: string | null | undefined): string {
|
||||
|
||||
try {
|
||||
const url = new URL(param, window.location.origin);
|
||||
if (
|
||||
url.origin === window.location.origin
|
||||
&& url.pathname.startsWith(`${prefix}/`)
|
||||
) {
|
||||
const underPrefix = prefix === ""
|
||||
? url.pathname.startsWith("/")
|
||||
: url.pathname === prefix || url.pathname.startsWith(`${prefix}/`);
|
||||
if (url.origin === window.location.origin && underPrefix) {
|
||||
return window.location.origin + url.pathname + url.search;
|
||||
}
|
||||
} catch {
|
||||
@@ -99,12 +101,16 @@ export function buildSubscribeContinueUrl(): string {
|
||||
// resumes) once the gate is cleared. Returns null for non-gate errors. Shared
|
||||
// by the route boundaries and the request-access flows so all gate handling
|
||||
// stays in one place.
|
||||
export function gateRedirectPath(error: unknown, continueUrl: string): string | null {
|
||||
export function gateRedirectPath(
|
||||
error: unknown,
|
||||
continueUrl: string,
|
||||
locale: UrlLocale = resolveUrlLocale(),
|
||||
): string | null {
|
||||
if (error instanceof FullNameRequiredError) {
|
||||
return `/full-name?continue=${encodeURIComponent(continueUrl)}`;
|
||||
return `${localizedPath(locale, "/full-name")}?continue=${encodeURIComponent(continueUrl)}`;
|
||||
}
|
||||
if (error instanceof NDASignatureRequiredError) {
|
||||
return `/nda?continue=${encodeURIComponent(continueUrl)}`;
|
||||
return `${localizedPath(locale, "/nda")}?continue=${encodeURIComponent(continueUrl)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
REQUEST_FILE_PARAM,
|
||||
REQUEST_REPORT_PARAM,
|
||||
} from "#/lib/auth/continueUrl";
|
||||
import { useLocale } from "#/lib/i18n/useLocale";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import type { useResumeAccessRequest_documentMutation } from "./__generated__/useResumeAccessRequest_documentMutation.graphql";
|
||||
@@ -105,6 +106,7 @@ const requestFileMutation = graphql`
|
||||
export function useResumeAccessRequest(isAuthenticated: boolean) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const locale = useLocale();
|
||||
const toast = Toast.useToastManager();
|
||||
const { t } = useTranslation();
|
||||
const firedRef = useRef(false);
|
||||
@@ -155,7 +157,7 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
|
||||
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const gatePath = gateRedirectPath(error, continueUrl);
|
||||
const gatePath = gateRedirectPath(error, continueUrl, locale);
|
||||
if (gatePath) {
|
||||
void navigate(gatePath);
|
||||
return;
|
||||
@@ -208,6 +210,7 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
|
||||
}).catch(() => {});
|
||||
}, [
|
||||
isAuthenticated,
|
||||
locale,
|
||||
navigate,
|
||||
requestAllAccesses,
|
||||
requestDocumentAccess,
|
||||
|
||||
@@ -22,8 +22,25 @@ import { createInstance } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import { DEFAULT_NAMESPACE, globBackend } from "./backend";
|
||||
import {
|
||||
isUrlLocale,
|
||||
urlLocaleToLanguage,
|
||||
} from "./locale";
|
||||
import { resolveLanguage, SUPPORTED_LANGUAGES } from "./resolveLanguage";
|
||||
|
||||
function initialLanguage() {
|
||||
const pathname = window.location.pathname;
|
||||
const trustMatch = pathname.match(/^\/trust\/[^/]+(\/.*)?$/);
|
||||
const appPath = trustMatch
|
||||
? (trustMatch[1] && trustMatch[1].length > 0 ? trustMatch[1] : "/")
|
||||
: pathname;
|
||||
const first = appPath.split("/").filter(Boolean)[0];
|
||||
if (isUrlLocale(first)) {
|
||||
return urlLocaleToLanguage(first);
|
||||
}
|
||||
return resolveLanguage();
|
||||
}
|
||||
|
||||
// Build a dedicated instance rather than mutating i18next's global singleton.
|
||||
// Initializing it through initReactI18next still registers it as the instance
|
||||
// react-i18next's hooks read from, so no I18nextProvider is required.
|
||||
@@ -33,7 +50,7 @@ void i18n
|
||||
.use(globBackend)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: resolveLanguage(),
|
||||
lng: initialLanguage(),
|
||||
fallbackLng: "en-US",
|
||||
supportedLngs: SUPPORTED_LANGUAGES,
|
||||
load: "currentOnly",
|
||||
|
||||
149
apps/compliance-portal/src/lib/i18n/locale.ts
Normal file
149
apps/compliance-portal/src/lib/i18n/locale.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import {
|
||||
resolveLanguage,
|
||||
type SupportedLanguage,
|
||||
} from "./resolveLanguage";
|
||||
|
||||
// Short tags used in URL path segments (and persisted on Identity.locale).
|
||||
export const URL_LOCALES = [
|
||||
"en",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"pl",
|
||||
"pt",
|
||||
"tr",
|
||||
"uk",
|
||||
"zh",
|
||||
] as const;
|
||||
|
||||
export type UrlLocale = (typeof URL_LOCALES)[number];
|
||||
|
||||
const DEFAULT_URL_LOCALE: UrlLocale = "en";
|
||||
|
||||
const LANGUAGE_TO_URL_LOCALE: Record<SupportedLanguage, UrlLocale> = {
|
||||
"en-US": "en",
|
||||
"fr-FR": "fr",
|
||||
"de-DE": "de",
|
||||
"es-ES": "es",
|
||||
"id-ID": "id",
|
||||
"it-IT": "it",
|
||||
"ja-JP": "ja",
|
||||
"ko-KR": "ko",
|
||||
"pl-PL": "pl",
|
||||
"pt-PT": "pt",
|
||||
"tr-TR": "tr",
|
||||
"uk-UA": "uk",
|
||||
"zh-CN": "zh",
|
||||
};
|
||||
|
||||
const URL_LOCALE_TO_LANGUAGE = Object.fromEntries(
|
||||
Object.entries(LANGUAGE_TO_URL_LOCALE).map(([language, locale]) => [locale, language]),
|
||||
) as Record<UrlLocale, SupportedLanguage>;
|
||||
|
||||
// Native-language labels for the locale switcher (not translated via i18n).
|
||||
export const URL_LOCALE_LABELS: Record<UrlLocale, string> = {
|
||||
en: "English",
|
||||
fr: "Français",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
id: "Bahasa Indonesia",
|
||||
it: "Italiano",
|
||||
ja: "日本語",
|
||||
ko: "한국어",
|
||||
pl: "Polski",
|
||||
pt: "Português",
|
||||
tr: "Türkçe",
|
||||
uk: "Українська",
|
||||
zh: "中文",
|
||||
};
|
||||
|
||||
export function isUrlLocale(value: string | undefined | null): value is UrlLocale {
|
||||
return value != null && (URL_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function languageToUrlLocale(language: SupportedLanguage): UrlLocale {
|
||||
return LANGUAGE_TO_URL_LOCALE[language];
|
||||
}
|
||||
|
||||
export function urlLocaleToLanguage(locale: UrlLocale): SupportedLanguage {
|
||||
return URL_LOCALE_TO_LANGUAGE[locale];
|
||||
}
|
||||
|
||||
export function resolveUrlLocale(): UrlLocale {
|
||||
return languageToUrlLocale(resolveLanguage());
|
||||
}
|
||||
|
||||
export function parseUrlLocale(value: string | undefined | null): UrlLocale | null {
|
||||
if (!isUrlLocale(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function defaultUrlLocale(): UrlLocale {
|
||||
return DEFAULT_URL_LOCALE;
|
||||
}
|
||||
|
||||
// Prefix an app-absolute path with a locale segment. Paths must start with "/".
|
||||
// "/" becomes "/en"; "/documents" becomes "/en/documents".
|
||||
export function localizedPath(locale: UrlLocale, path: string): string {
|
||||
if (!path.startsWith("/")) {
|
||||
throw new Error(`localizedPath expects an absolute path, got: ${path}`);
|
||||
}
|
||||
if (path === "/") {
|
||||
return `/${locale}`;
|
||||
}
|
||||
return `/${locale}${path}`;
|
||||
}
|
||||
|
||||
// Swap the locale prefix on a basename-relative pathname (e.g. "/en/documents"
|
||||
// → "/fr/documents"). Paths without a valid locale prefix are treated as
|
||||
// unprefixed and get the new locale prepended.
|
||||
export function replaceLocaleInPathname(pathname: string, locale: UrlLocale): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return `/${locale}`;
|
||||
}
|
||||
if (isUrlLocale(segments[0])) {
|
||||
segments[0] = locale;
|
||||
return `/${segments.join("/")}`;
|
||||
}
|
||||
return `/${locale}/${segments.join("/")}`;
|
||||
}
|
||||
|
||||
// Strip a leading locale segment from a basename-relative pathname.
|
||||
export function stripLocaleFromPathname(pathname: string): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return "/";
|
||||
}
|
||||
if (isUrlLocale(segments[0])) {
|
||||
const rest = segments.slice(1);
|
||||
return rest.length === 0 ? "/" : `/${rest.join("/")}`;
|
||||
}
|
||||
return pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
}
|
||||
64
apps/compliance-portal/src/lib/i18n/localeRedirect.ts
Normal file
64
apps/compliance-portal/src/lib/i18n/localeRedirect.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { redirect, type LoaderFunctionArgs } from "react-router";
|
||||
|
||||
import {
|
||||
isUrlLocale,
|
||||
localizedPath,
|
||||
resolveUrlLocale,
|
||||
} from "./locale";
|
||||
|
||||
// Ensures `/:lang` is a supported short tag. Legacy unprefixed URLs
|
||||
// (/documents) and unknown two-letter tags are rewritten to a guessed locale.
|
||||
export function localeLayoutLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const appPath = stripBasename(url.pathname);
|
||||
const segments = appPath.split("/").filter(Boolean);
|
||||
const first = segments[0];
|
||||
|
||||
if (isUrlLocale(first)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const guess = resolveUrlLocale();
|
||||
|
||||
if (segments.length === 0) {
|
||||
throw redirect(`/${guess}${url.search}`);
|
||||
}
|
||||
|
||||
// Unknown two-letter tag (e.g. /xx/documents) → drop it and keep the rest.
|
||||
if (/^[a-z]{2}$/.test(first)) {
|
||||
const rest = segments.slice(1);
|
||||
const path = rest.length === 0 ? "/" : `/${rest.join("/")}`;
|
||||
throw redirect(localizedPath(guess, path) + url.search);
|
||||
}
|
||||
|
||||
// First segment is a real route (documents, updates, …) — prefix locale.
|
||||
throw redirect(localizedPath(guess, appPath) + url.search);
|
||||
}
|
||||
|
||||
function stripBasename(pathname: string): string {
|
||||
const match = pathname.match(/^\/trust\/[^/]+(\/.*)?$/);
|
||||
if (match) {
|
||||
return match[1] && match[1].length > 0 ? match[1] : "/";
|
||||
}
|
||||
return pathname || "/";
|
||||
}
|
||||
56
apps/compliance-portal/src/lib/i18n/useChangeLocale.ts
Normal file
56
apps/compliance-portal/src/lib/i18n/useChangeLocale.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import {
|
||||
replaceLocaleInPathname,
|
||||
type UrlLocale,
|
||||
} from "./locale";
|
||||
import { useLocale } from "./useLocale";
|
||||
import { useUpdateLocale } from "./useUpdateLocale";
|
||||
|
||||
interface ChangeLocaleOptions {
|
||||
// When true, also persist the locale on the signed-in identity.
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
// Navigate to the same path under a new locale; optionally persist for signed-in users.
|
||||
export function useChangeLocale() {
|
||||
const currentLocale = useLocale();
|
||||
const { pathname, search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [updateLocale, isUpdating] = useUpdateLocale();
|
||||
|
||||
const changeLocale = useCallback(async (
|
||||
locale: UrlLocale,
|
||||
options: ChangeLocaleOptions = {},
|
||||
) => {
|
||||
if (options.persist) {
|
||||
await updateLocale(locale);
|
||||
}
|
||||
if (locale !== currentLocale) {
|
||||
void navigate(replaceLocaleInPathname(pathname, locale) + search);
|
||||
}
|
||||
}, [currentLocale, navigate, pathname, search, updateLocale]);
|
||||
|
||||
return [changeLocale, isUpdating] as const;
|
||||
}
|
||||
44
apps/compliance-portal/src/lib/i18n/useLocale.ts
Normal file
44
apps/compliance-portal/src/lib/i18n/useLocale.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import {
|
||||
defaultUrlLocale,
|
||||
isUrlLocale,
|
||||
localizedPath,
|
||||
type UrlLocale,
|
||||
} from "./locale";
|
||||
|
||||
// Current URL locale from the `/:lang` route param. Falls back to English when
|
||||
// the param is missing (should not happen under the locale layout).
|
||||
export function useLocale(): UrlLocale {
|
||||
const { lang } = useParams();
|
||||
if (isUrlLocale(lang)) {
|
||||
return lang;
|
||||
}
|
||||
return defaultUrlLocale();
|
||||
}
|
||||
|
||||
// Build a locale-prefixed app path for the active URL locale.
|
||||
export function useLocalizedPath() {
|
||||
const locale = useLocale();
|
||||
return (path: string) => localizedPath(locale, path);
|
||||
}
|
||||
55
apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts
Normal file
55
apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import type { useUpdateLocaleMutation } from "./__generated__/useUpdateLocaleMutation.graphql";
|
||||
import type { UrlLocale } from "./locale";
|
||||
|
||||
const updateLocaleMutation = graphql`
|
||||
mutation useUpdateLocaleMutation($input: UpdateLocaleInput!) {
|
||||
updateLocale(input: $input) {
|
||||
identity {
|
||||
id
|
||||
locale
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Persists the viewer's preferred UI locale on their identity.
|
||||
export function useUpdateLocale() {
|
||||
const { t } = useTranslation();
|
||||
const [commit, isUpdating] = useMutation<useUpdateLocaleMutation>(updateLocaleMutation, {
|
||||
errorToast: t("locale.updateFailed"),
|
||||
});
|
||||
|
||||
const updateLocale = useCallback(async (locale: UrlLocale) => {
|
||||
await commit({
|
||||
variables: { input: { locale } },
|
||||
});
|
||||
}, [commit]);
|
||||
|
||||
return [updateLocale, isUpdating] as const;
|
||||
}
|
||||
48
apps/compliance-portal/src/pages/LocaleLayout.tsx
Normal file
48
apps/compliance-portal/src/pages/LocaleLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet, useParams } from "react-router";
|
||||
|
||||
import {
|
||||
isUrlLocale,
|
||||
urlLocaleToLanguage,
|
||||
} from "#/lib/i18n/locale";
|
||||
|
||||
// Syncs i18next and <html lang> with the `/:lang` route param. Locale validation
|
||||
// and unprefixed redirects happen in the route loader before this mounts.
|
||||
export function LocaleLayout() {
|
||||
const { lang } = useParams();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUrlLocale(lang)) {
|
||||
return;
|
||||
}
|
||||
const language = urlLocaleToLanguage(lang);
|
||||
if (i18n.language !== language) {
|
||||
void i18n.changeLanguage(language);
|
||||
}
|
||||
document.documentElement.lang = lang;
|
||||
}, [lang, i18n]);
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { PreloadedQuery } from "react-relay";
|
||||
import { graphql, usePreloadedQuery } from "react-relay";
|
||||
import { Outlet, useMatch } from "react-router";
|
||||
|
||||
import { LocaleMismatchCallout } from "#/components/LocaleMismatchCallout";
|
||||
import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
|
||||
import { TopBar } from "#/components/TopBar/TopBar";
|
||||
import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest";
|
||||
@@ -34,6 +35,7 @@ export const mainLayoutQuery = graphql`
|
||||
query MainLayoutQuery {
|
||||
viewer {
|
||||
__typename
|
||||
...LocaleMismatchCallout_identity
|
||||
}
|
||||
...TopBar_query
|
||||
...SubscribeDialogProvider_query
|
||||
@@ -47,7 +49,7 @@ interface MainLayoutProps {
|
||||
export function MainLayout({ queryRef }: MainLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
||||
const isDocumentViewer = useMatch("documents/:alias") != null;
|
||||
const isDocumentViewer = useMatch("/:lang/documents/:alias") != null;
|
||||
|
||||
// Resume a deferred "request access" once the user lands back authenticated.
|
||||
useResumeAccessRequest(data.viewer != null);
|
||||
@@ -65,6 +67,9 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
|
||||
}
|
||||
>
|
||||
<TopBar queryKey={data} />
|
||||
{data.viewer != null
|
||||
? <LocaleMismatchCallout identityKey={data.viewer} />
|
||||
: null}
|
||||
<div
|
||||
className={
|
||||
isDocumentViewer
|
||||
|
||||
@@ -24,11 +24,13 @@ import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
|
||||
// Catch-all page for portal paths that match no route, so an unknown URL renders
|
||||
// an explicit not-found state inside the layout instead of an empty body.
|
||||
export default function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
const localizedPath = useLocalizedPath();
|
||||
|
||||
return (
|
||||
<HeaderBand>
|
||||
@@ -39,7 +41,7 @@ export default function NotFoundPage() {
|
||||
<Text size={2} color="neutral">
|
||||
{t("notFound.description")}
|
||||
</Text>
|
||||
<Link to="/" variant="soft" color="neutral" highContrast size={2}>
|
||||
<Link to={localizedPath("/")} variant="soft" color="neutral" highContrast size={2}>
|
||||
{t("notFound.backHome")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
|
||||
import { useRequestReportAccess } from "../_lib/useAccessRequest";
|
||||
|
||||
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
|
||||
@@ -50,6 +52,7 @@ interface AuditReportListItemProps {
|
||||
// access action linking to the viewer when authorized. Renders nothing when the
|
||||
// audit has no report file.
|
||||
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
||||
const localizedPath = useLocalizedPath();
|
||||
const audit = useFragment(auditReportListItemFragment, auditKey);
|
||||
const report = audit.reportFile;
|
||||
// Hook must run unconditionally; the empty id is never used when there is no
|
||||
@@ -66,7 +69,7 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
||||
meta={report.fileName}
|
||||
isAuthorized={report.isUserAuthorized}
|
||||
requested={report.access?.status === "REQUESTED"}
|
||||
viewHref={`/documents/${encodeURIComponent(report.alias ?? report.id)}`}
|
||||
viewHref={localizedPath(`/documents/${encodeURIComponent(report.alias ?? report.id)}`)}
|
||||
onGetAccess={requestAccess}
|
||||
isRequesting={isRequesting}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
|
||||
import { useRequestFileAccess } from "../_lib/useAccessRequest";
|
||||
|
||||
import type { CompliancePortalFileListItem_file$key } from "./__generated__/CompliancePortalFileListItem_file.graphql";
|
||||
@@ -45,6 +47,7 @@ interface CompliancePortalFileListItemProps {
|
||||
// A single uploaded trust-center file entry: name, its category, and an access
|
||||
// action linking to the viewer when authorized.
|
||||
export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileListItemProps) {
|
||||
const localizedPath = useLocalizedPath();
|
||||
const file = useFragment(compliancePortalFileListItemFragment, fileKey);
|
||||
const { requestAccess, isRequesting } = useRequestFileAccess(file.id);
|
||||
|
||||
@@ -54,7 +57,7 @@ export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileLi
|
||||
meta={file.category}
|
||||
isAuthorized={file.isUserAuthorized}
|
||||
requested={file.access?.status === "REQUESTED"}
|
||||
viewHref={`/documents/${encodeURIComponent(file.alias ?? file.id)}`}
|
||||
viewHref={localizedPath(`/documents/${encodeURIComponent(file.alias ?? file.id)}`)}
|
||||
onGetAccess={requestAccess}
|
||||
isRequesting={isRequesting}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
|
||||
import { useRequestDocumentAccess } from "../_lib/useAccessRequest";
|
||||
|
||||
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
||||
@@ -47,6 +49,7 @@ interface DocumentListItemProps {
|
||||
// linking to the viewer when authorized.
|
||||
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
const localizedPath = useLocalizedPath();
|
||||
const document = useFragment(documentListItemFragment, documentKey);
|
||||
const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id);
|
||||
|
||||
@@ -56,7 +59,7 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||
meta={t(`types.${document.documentType}`)}
|
||||
isAuthorized={document.isUserAuthorized}
|
||||
requested={document.access?.status === "REQUESTED"}
|
||||
viewHref={`/documents/${encodeURIComponent(document.alias ?? document.id)}`}
|
||||
viewHref={localizedPath(`/documents/${encodeURIComponent(document.alias ?? document.id)}`)}
|
||||
onGetAccess={requestAccess}
|
||||
isRequesting={isRequesting}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
|
||||
import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri";
|
||||
|
||||
@@ -69,6 +70,7 @@ interface DocumentViewerProps {
|
||||
export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
const toast = Toast.useToastManager();
|
||||
const localizedPath = useLocalizedPath();
|
||||
|
||||
const pdfRef = useRef<PdfPreviewHandle>(null);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
@@ -104,7 +106,7 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
|
||||
<div className={slots.root()}>
|
||||
<HeaderBand flushBottomSpace>
|
||||
<div className={slots.header()}>
|
||||
<Link to="/documents" variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
|
||||
<Link to={localizedPath("/documents")} variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
|
||||
{t("viewer.back")}
|
||||
</Link>
|
||||
<Heading level={1} size={7} weight="medium" highContrast className="truncate">
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
REQUEST_FILE_PARAM,
|
||||
REQUEST_REPORT_PARAM,
|
||||
} from "#/lib/auth/continueUrl";
|
||||
import { useLocale } from "#/lib/i18n/useLocale";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import type { useAccessRequestDocumentMutation } from "./__generated__/useAccessRequestDocumentMutation.graphql";
|
||||
@@ -100,6 +101,7 @@ const fileMutation = graphql`
|
||||
// URL so it resumes once the gate is cleared. Everything else is a generic toast.
|
||||
function useAccessRequestHandlers(param: string, id: string) {
|
||||
const navigate = useNavigate();
|
||||
const locale = useLocale();
|
||||
const toast = Toast.useToastManager();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -123,7 +125,7 @@ function useAccessRequestHandlers(param: string, id: string) {
|
||||
}
|
||||
// Full-name / NDA gate: deep-link to the gate page, preserving the
|
||||
// marker so the request resumes afterwards.
|
||||
const gatePath = gateRedirectPath(error, continueUrl);
|
||||
const gatePath = gateRedirectPath(error, continueUrl, locale);
|
||||
if (gatePath) {
|
||||
void navigate(gatePath);
|
||||
return;
|
||||
@@ -131,7 +133,7 @@ function useAccessRequestHandlers(param: string, id: string) {
|
||||
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
|
||||
},
|
||||
}),
|
||||
[navigate, toast, t, param, id],
|
||||
[navigate, locale, toast, t, param, id],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Navigate, useSearchParams } from "react-router";
|
||||
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
import { getSafeContinueUrl } from "#/lib/auth/continueUrl";
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
import { PdfPreview, type PdfPreviewHandle } from "#/pages/documents/_components/PdfPreview";
|
||||
|
||||
@@ -114,6 +115,7 @@ interface NDAPageProps {
|
||||
// continue URL. Reached from the route boundary on NDA_SIGNATURE_REQUIRED.
|
||||
export function NDAPage({ queryRef }: NDAPageProps) {
|
||||
const { t } = useTranslation("nda");
|
||||
const localizedPath = useLocalizedPath();
|
||||
const [searchParams] = useSearchParams();
|
||||
const documentViewedRef = useRef(false);
|
||||
const pdfRef = useRef<PdfPreviewHandle>(null);
|
||||
@@ -212,11 +214,11 @@ export function NDAPage({ queryRef }: NDAPageProps) {
|
||||
};
|
||||
|
||||
if (!data.viewer) {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to={localizedPath("/")} replace />;
|
||||
}
|
||||
|
||||
if (!nda || !signature) {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to={localizedPath("/")} replace />;
|
||||
}
|
||||
|
||||
// Signature already sealed: the effect above redirects to the continue URL;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { graphql, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
import { formatDate } from "#/lib/datetime/formatDate";
|
||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||
import { NotFoundError } from "#/lib/relay/errors";
|
||||
|
||||
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
|
||||
@@ -53,6 +54,7 @@ interface UpdateDetailPageProps {
|
||||
|
||||
export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
||||
const { t, i18n } = useTranslation("updates");
|
||||
const localizedPath = useLocalizedPath();
|
||||
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
|
||||
|
||||
if (data.node?.__typename !== "MailingListUpdate") {
|
||||
@@ -66,7 +68,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
||||
<>
|
||||
<HeaderBand>
|
||||
<div className={toolbar()}>
|
||||
<Link to="/updates" variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
||||
<Link to={localizedPath("/updates")} variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
||||
{t("backToUpdates")}
|
||||
</Link>
|
||||
<UpdatesSubscribeButton />
|
||||
|
||||
@@ -26,9 +26,12 @@ import { createBrowserRouter, redirect } from "react-router";
|
||||
import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary";
|
||||
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
|
||||
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||
import { localeLayoutLoader } from "#/lib/i18n/localeRedirect";
|
||||
import { resolveUrlLocale } from "#/lib/i18n/locale";
|
||||
import { authRoutes } from "#/pages/auth/routes";
|
||||
import { documentRoutes } from "#/pages/documents/routes";
|
||||
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
||||
import { LocaleLayout } from "#/pages/LocaleLayout";
|
||||
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
||||
import { ndaRoutes } from "#/pages/nda/routes";
|
||||
import { requestRoutes } from "#/pages/requests/routes";
|
||||
@@ -37,45 +40,64 @@ import { updateRoutes } from "#/pages/updates/routes";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
Fallback: MainLayoutSkeleton,
|
||||
Component: lazy(() => import("#/pages/MainLayoutLoader")),
|
||||
// A layout failure takes down the shell, so it shows a standalone full page.
|
||||
path: "/:lang",
|
||||
loader: localeLayoutLoader,
|
||||
Component: LocaleLayout,
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
{
|
||||
// Pathless layout route: page failures bubble here and render inside the
|
||||
// MainLayout Outlet, keeping the TopBar and footer chrome.
|
||||
ErrorBoundary: PageErrorBoundary,
|
||||
// Pathless: MainLayout wraps portal pages under /:lang without adding a
|
||||
// segment. (A child path of "/" would be absolute and bypass /:lang.)
|
||||
Fallback: MainLayoutSkeleton,
|
||||
Component: lazy(() => import("#/pages/MainLayoutLoader")),
|
||||
// A layout failure takes down the shell, so it shows a standalone full page.
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
Fallback: HomePageSkeleton,
|
||||
Component: lazy(() => import("#/pages/HomePageLoader")),
|
||||
},
|
||||
// Legacy trust-app URL; home now lives at the index route.
|
||||
{
|
||||
path: "overview",
|
||||
loader: () => {
|
||||
// eslint-disable-next-line
|
||||
throw redirect("/");
|
||||
},
|
||||
Component: Fragment,
|
||||
},
|
||||
...documentRoutes,
|
||||
...subprocessorRoutes,
|
||||
...updateRoutes,
|
||||
...requestRoutes,
|
||||
{
|
||||
path: "*",
|
||||
Component: lazy(() => import("#/pages/NotFoundPage")),
|
||||
// Pathless layout route: page failures bubble here and render inside the
|
||||
// MainLayout Outlet, keeping the TopBar and footer chrome.
|
||||
ErrorBoundary: PageErrorBoundary,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
Fallback: HomePageSkeleton,
|
||||
Component: lazy(() => import("#/pages/HomePageLoader")),
|
||||
},
|
||||
// Legacy trust-app URL; home now lives at the index route.
|
||||
{
|
||||
path: "overview",
|
||||
loader: ({ params }) => {
|
||||
const lang = params.lang ?? resolveUrlLocale();
|
||||
// eslint-disable-next-line
|
||||
throw redirect(`/${lang}`);
|
||||
},
|
||||
Component: Fragment,
|
||||
},
|
||||
...documentRoutes,
|
||||
...subprocessorRoutes,
|
||||
...updateRoutes,
|
||||
...requestRoutes,
|
||||
{
|
||||
path: "*",
|
||||
Component: lazy(() => import("#/pages/NotFoundPage")),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...authRoutes,
|
||||
...ndaRoutes,
|
||||
],
|
||||
},
|
||||
...authRoutes,
|
||||
...ndaRoutes,
|
||||
// Bare basename root (no locale segment) → guessed locale home.
|
||||
{
|
||||
path: "/",
|
||||
loader: () => {
|
||||
// eslint-disable-next-line
|
||||
throw redirect(`/${resolveUrlLocale()}`);
|
||||
},
|
||||
Component: Fragment,
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
// The portal is served under a /trust/{slug} path prefix (or a bare custom
|
||||
|
||||
@@ -44,6 +44,7 @@ type (
|
||||
HashedPassword []byte `db:"hashed_password"`
|
||||
EmailAddressVerified bool `db:"email_address_verified"`
|
||||
SAMLSubject *string `db:"saml_subject"`
|
||||
Locale *string `db:"locale"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
@@ -74,6 +75,7 @@ SELECT
|
||||
hashed_password,
|
||||
email_address_verified,
|
||||
saml_subject,
|
||||
locale,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -118,6 +120,7 @@ SELECT
|
||||
hashed_password,
|
||||
email_address_verified,
|
||||
saml_subject,
|
||||
locale,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -206,7 +209,17 @@ func (i *Identity) Insert(
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
identities (id, email_address, full_name, hashed_password, email_address_verified, saml_subject, created_at, updated_at)
|
||||
identities (
|
||||
id,
|
||||
email_address,
|
||||
full_name,
|
||||
hashed_password,
|
||||
email_address_verified,
|
||||
saml_subject,
|
||||
locale,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
@identity_id,
|
||||
@email_address,
|
||||
@@ -214,6 +227,7 @@ VALUES (
|
||||
@hashed_password,
|
||||
@email_address_verified,
|
||||
@saml_subject,
|
||||
@locale,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
@@ -225,6 +239,7 @@ VALUES (
|
||||
"full_name": i.FullName,
|
||||
"hashed_password": i.HashedPassword,
|
||||
"saml_subject": i.SAMLSubject,
|
||||
"locale": i.Locale,
|
||||
"created_at": i.CreatedAt,
|
||||
"updated_at": i.UpdatedAt,
|
||||
"email_address_verified": i.EmailAddressVerified,
|
||||
@@ -254,6 +269,7 @@ SET
|
||||
email_address_verified = @email_address_verified,
|
||||
saml_subject = @saml_subject,
|
||||
hashed_password = @hashed_password,
|
||||
locale = @locale,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
id = @identity_id
|
||||
@@ -265,8 +281,9 @@ WHERE
|
||||
"full_name": i.FullName,
|
||||
"email_address_verified": i.EmailAddressVerified,
|
||||
"saml_subject": i.SAMLSubject,
|
||||
"updated_at": i.UpdatedAt,
|
||||
"hashed_password": i.HashedPassword,
|
||||
"locale": i.Locale,
|
||||
"updated_at": i.UpdatedAt,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
@@ -295,6 +312,7 @@ SELECT
|
||||
hashed_password,
|
||||
email_address_verified,
|
||||
saml_subject,
|
||||
locale,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
|
||||
2
pkg/coredata/migrations/20260721T084500Z.sql
Normal file
2
pkg/coredata/migrations/20260721T084500Z.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE identities
|
||||
ADD COLUMN locale TEXT NULL;
|
||||
@@ -62,8 +62,18 @@ type (
|
||||
UpdateIdentityRequest struct {
|
||||
FullName string `json:"fullName"`
|
||||
}
|
||||
|
||||
UpdateLocaleRequest struct {
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
)
|
||||
|
||||
// Short URL locale tags accepted for Identity.locale (must stay in sync with
|
||||
// the compliance-portal URL_LOCALES list).
|
||||
var supportedIdentityLocales = []string{
|
||||
"en", "fr", "de", "es", "id", "it", "ja", "ko", "pl", "pt", "tr", "uk", "zh",
|
||||
}
|
||||
|
||||
const (
|
||||
TokenTypeEmailConfirmation = "email_confirmation"
|
||||
)
|
||||
@@ -88,6 +98,20 @@ func (req UpdateIdentityRequest) Validate() error {
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (req UpdateLocaleRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(
|
||||
req.Locale,
|
||||
"locale",
|
||||
validator.NotEmpty(),
|
||||
validator.MaxLen(8),
|
||||
validator.OneOfSlice(supportedIdentityLocales),
|
||||
)
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req *ChangeEmailRequest) error {
|
||||
if err := req.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid request: %w", err)
|
||||
@@ -402,6 +426,42 @@ func (s AccountService) UpdateIdentity(ctx context.Context, identityID gid.GID,
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (s AccountService) UpdateLocale(ctx context.Context, identityID gid.GID, req *UpdateLocaleRequest) (*coredata.Identity, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
identity := &coredata.Identity{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
err := identity.LoadByID(ctx, tx, identityID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewIdentityNotFoundError(identityID)
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot load identity: %w", err)
|
||||
}
|
||||
|
||||
identity.Locale = &req.Locale
|
||||
identity.UpdatedAt = time.Now()
|
||||
|
||||
if err := identity.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot update identity locale: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (s AccountService) ListPersonalAPIKeys(
|
||||
ctx context.Context,
|
||||
identityID gid.GID,
|
||||
|
||||
@@ -97,6 +97,42 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat
|
||||
return &types.UpdateFullNamePayload{Success: true}, nil
|
||||
}
|
||||
|
||||
// UpdateLocale is the resolver for the updateLocale field.
|
||||
func (r *mutationResolver) UpdateLocale(ctx context.Context, input types.UpdateLocaleInput) (*types.UpdateLocalePayload, error) {
|
||||
identity := authn.IdentityFromContext(ctx)
|
||||
if identity == nil {
|
||||
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to update locale")
|
||||
}
|
||||
|
||||
identity, err := r.iam.AccountService.UpdateLocale(
|
||||
ctx,
|
||||
identity.ID,
|
||||
&iam.UpdateLocaleRequest{
|
||||
Locale: input.Locale,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot update identity locale", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.UpdateLocalePayload{
|
||||
Identity: &types.Identity{
|
||||
ID: identity.ID,
|
||||
Email: identity.EmailAddress,
|
||||
FullName: identity.FullName,
|
||||
EmailVerified: identity.EmailAddressVerified,
|
||||
Locale: identity.Locale,
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SignOut is the resolver for the signOut field.
|
||||
func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload, error) {
|
||||
session := authn.SessionFromContext(ctx)
|
||||
|
||||
@@ -35,6 +35,7 @@ func (r *queryResolver) Viewer(ctx context.Context) (*types.Identity, error) {
|
||||
Email: identity.EmailAddress,
|
||||
FullName: identity.FullName,
|
||||
EmailVerified: identity.EmailAddressVerified,
|
||||
Locale: identity.Locale,
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
}, nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
extend type Mutation {
|
||||
updateFullName(input: UpdateFullNameInput!): UpdateFullNamePayload
|
||||
@authentication(required: PRESENT) @sessionOnly
|
||||
updateLocale(input: UpdateLocaleInput!): UpdateLocalePayload
|
||||
@authentication(required: PRESENT) @sessionOnly
|
||||
signOut: SignOutPayload! @authentication(required: PRESENT) @sessionOnly
|
||||
}
|
||||
|
||||
@@ -12,6 +14,15 @@ type UpdateFullNamePayload {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
input UpdateLocaleInput {
|
||||
# Short URL locale tag (en, fr, de, …).
|
||||
locale: String!
|
||||
}
|
||||
|
||||
type UpdateLocalePayload {
|
||||
identity: Identity!
|
||||
}
|
||||
|
||||
type SignOutPayload {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ type Identity implements Node {
|
||||
email: EmailAddr!
|
||||
fullName: String!
|
||||
emailVerified: Boolean!
|
||||
# Preferred UI locale as a short URL tag (en, fr, de, …). Null until the
|
||||
# viewer explicitly chooses a language in the portal.
|
||||
locale: String
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
@@ -134,16 +134,21 @@ func compliancePageHeadData() HeadDataFunc {
|
||||
}
|
||||
|
||||
compliancePageBaseURL := complianceportal.CompliancePortalBaseURLFromContext(r.Context())
|
||||
pageBase := ref.UnrefOrZero(compliancePageBaseURL)
|
||||
htmlLang, canonical, hreflang := SEOFromRequest(r, pageBase)
|
||||
|
||||
description := tc.Title + " Compliance Page"
|
||||
description := tc.Title
|
||||
if tc.Description != nil && *tc.Description != "" {
|
||||
description = *tc.Description
|
||||
}
|
||||
|
||||
headData := HeadData{
|
||||
Title: tc.Title,
|
||||
Description: description,
|
||||
OGURL: ref.UnrefOrZero(compliancePageBaseURL),
|
||||
Title: tc.Title,
|
||||
Description: description,
|
||||
OGURL: pageBase,
|
||||
HtmlLang: htmlLang,
|
||||
CanonicalURL: canonical,
|
||||
Hreflang: hreflang,
|
||||
}
|
||||
|
||||
if tc.LogoFileID != nil && compliancePageBaseURL != nil {
|
||||
|
||||
116
pkg/server/api/complianceportal/v1/seo.go
Normal file
116
pkg/server/api/complianceportal/v1/seo.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package complianceportal_v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Short locale tags used in compliance-portal URL paths. Keep in sync with the
|
||||
// frontend URL_LOCALES list and iam.supportedIdentityLocales.
|
||||
var compliancePortalLocales = []string{
|
||||
"en", "fr", "de", "es", "id", "it", "ja", "ko", "pl", "pt", "tr", "uk", "zh",
|
||||
}
|
||||
|
||||
const defaultCompliancePortalLocale = "en"
|
||||
|
||||
// SEOFromRequest derives html lang, a self-referencing canonical URL, and
|
||||
// hreflang alternates (including x-default → English) for the SPA shell.
|
||||
func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical string, hreflang []HreflangLink) {
|
||||
appPath := complianceAppPath(r.URL.Path)
|
||||
locale, rest := splitLocaleFromAppPath(appPath)
|
||||
|
||||
htmlLang = locale
|
||||
canonical = localizedPageURL(pageBaseURL, locale, rest)
|
||||
|
||||
hreflang = make([]HreflangLink, 0, len(compliancePortalLocales)+1)
|
||||
for _, loc := range compliancePortalLocales {
|
||||
hreflang = append(hreflang, HreflangLink{
|
||||
Lang: loc,
|
||||
Href: localizedPageURL(pageBaseURL, loc, rest),
|
||||
})
|
||||
}
|
||||
hreflang = append(hreflang, HreflangLink{
|
||||
Lang: "x-default",
|
||||
Href: localizedPageURL(pageBaseURL, defaultCompliancePortalLocale, rest),
|
||||
})
|
||||
|
||||
return htmlLang, canonical, hreflang
|
||||
}
|
||||
|
||||
// complianceAppPath returns the path relative to the portal root: under
|
||||
// /trust/:slug it strips that prefix; on a custom domain it returns the path as-is.
|
||||
func complianceAppPath(pathname string) string {
|
||||
trimmed := strings.TrimPrefix(pathname, "/")
|
||||
if strings.HasPrefix(trimmed, "trust/") {
|
||||
parts := strings.SplitN(trimmed, "/", 3)
|
||||
if len(parts) < 2 {
|
||||
return "/"
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return "/"
|
||||
}
|
||||
return "/" + parts[2]
|
||||
}
|
||||
if pathname == "" {
|
||||
return "/"
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
func splitLocaleFromAppPath(appPath string) (locale, rest string) {
|
||||
segments := strings.Split(strings.Trim(appPath, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return defaultCompliancePortalLocale, "/"
|
||||
}
|
||||
|
||||
if isCompliancePortalLocale(segments[0]) {
|
||||
locale = segments[0]
|
||||
if len(segments) == 1 {
|
||||
return locale, "/"
|
||||
}
|
||||
return locale, "/" + strings.Join(segments[1:], "/")
|
||||
}
|
||||
|
||||
// Unprefixed legacy path — treat content path as-is; default lang for tags.
|
||||
return defaultCompliancePortalLocale, appPath
|
||||
}
|
||||
|
||||
func isCompliancePortalLocale(value string) bool {
|
||||
for _, locale := range compliancePortalLocales {
|
||||
if locale == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func localizedPageURL(pageBaseURL, locale, rest string) string {
|
||||
base := strings.TrimRight(pageBaseURL, "/")
|
||||
if rest == "/" || rest == "" {
|
||||
return base + "/" + locale
|
||||
}
|
||||
if !strings.HasPrefix(rest, "/") {
|
||||
rest = "/" + rest
|
||||
}
|
||||
return base + "/" + locale + rest
|
||||
}
|
||||
55
pkg/server/api/complianceportal/v1/seo_test.go
Normal file
55
pkg/server/api/complianceportal/v1/seo_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package complianceportal_v1_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
complianceportal_v1 "go.probo.inc/probo/pkg/server/api/complianceportal/v1"
|
||||
)
|
||||
|
||||
func TestSEOFromRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://app.example.com/trust/acme/fr/documents", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
lang, canonical, hreflang := complianceportal_v1.SEOFromRequest(req, "https://app.example.com/trust/acme")
|
||||
assert.Equal(t, "fr", lang)
|
||||
assert.Equal(t, "https://app.example.com/trust/acme/fr/documents", canonical)
|
||||
require.NotEmpty(t, hreflang)
|
||||
|
||||
var xDefault string
|
||||
var enHref string
|
||||
for _, link := range hreflang {
|
||||
if link.Lang == "x-default" {
|
||||
xDefault = link.Href
|
||||
}
|
||||
if link.Lang == "en" {
|
||||
enHref = link.Href
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "https://app.example.com/trust/acme/en/documents", enHref)
|
||||
assert.Equal(t, enHref, xDefault)
|
||||
}
|
||||
@@ -33,10 +33,18 @@ import (
|
||||
|
||||
type (
|
||||
HeadData struct {
|
||||
Title string
|
||||
Description string
|
||||
OGURL string
|
||||
FaviconURL string
|
||||
Title string
|
||||
Description string
|
||||
OGURL string
|
||||
FaviconURL string
|
||||
HtmlLang string
|
||||
CanonicalURL string
|
||||
Hreflang []HreflangLink
|
||||
}
|
||||
|
||||
HreflangLink struct {
|
||||
Lang string
|
||||
Href string
|
||||
}
|
||||
|
||||
HeadDataFunc func(r *http.Request) HeadData
|
||||
|
||||
Reference in New Issue
Block a user