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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user