diff --git a/apps/compliance-portal/index.html b/apps/compliance-portal/index.html index f838646a4..328e0c81e 100644 --- a/apps/compliance-portal/index.html +++ b/apps/compliance-portal/index.html @@ -1,5 +1,5 @@ - + @@ -41,6 +41,9 @@ + {{if .CanonicalURL}}{{end}} + {{range .Hreflang}} + {{end}} diff --git a/apps/compliance-portal/src/_locales/de-DE.json b/apps/compliance-portal/src/_locales/de-DE.json index 954a33285..a065897d1 100644 --- a/apps/compliance-portal/src/_locales/de-DE.json +++ b/apps/compliance-portal/src/_locales/de-DE.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index df019b29e..49e1fdfc7 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -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", diff --git a/apps/compliance-portal/src/_locales/es-ES.json b/apps/compliance-portal/src/_locales/es-ES.json index 3c095d1bf..a0b588aa4 100644 --- a/apps/compliance-portal/src/_locales/es-ES.json +++ b/apps/compliance-portal/src/_locales/es-ES.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index d1aa365ae..7b21579ab 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/id-ID.json b/apps/compliance-portal/src/_locales/id-ID.json index cedeb33cc..6e2a8b308 100644 --- a/apps/compliance-portal/src/_locales/id-ID.json +++ b/apps/compliance-portal/src/_locales/id-ID.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/it-IT.json b/apps/compliance-portal/src/_locales/it-IT.json index 02098a62e..8bfdfd51b 100644 --- a/apps/compliance-portal/src/_locales/it-IT.json +++ b/apps/compliance-portal/src/_locales/it-IT.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/ja-JP.json b/apps/compliance-portal/src/_locales/ja-JP.json index b37eb19f3..82b8b1906 100644 --- a/apps/compliance-portal/src/_locales/ja-JP.json +++ b/apps/compliance-portal/src/_locales/ja-JP.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/ko-KR.json b/apps/compliance-portal/src/_locales/ko-KR.json index d9082e48f..447b402cd 100644 --- a/apps/compliance-portal/src/_locales/ko-KR.json +++ b/apps/compliance-portal/src/_locales/ko-KR.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/pl-PL.json b/apps/compliance-portal/src/_locales/pl-PL.json index dfd9f57c8..4bfa863dd 100644 --- a/apps/compliance-portal/src/_locales/pl-PL.json +++ b/apps/compliance-portal/src/_locales/pl-PL.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/pt-PT.json b/apps/compliance-portal/src/_locales/pt-PT.json index 20a4cf0e6..003760277 100644 --- a/apps/compliance-portal/src/_locales/pt-PT.json +++ b/apps/compliance-portal/src/_locales/pt-PT.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/tr-TR.json b/apps/compliance-portal/src/_locales/tr-TR.json index 40fa648a1..1e0da6dfa 100644 --- a/apps/compliance-portal/src/_locales/tr-TR.json +++ b/apps/compliance-portal/src/_locales/tr-TR.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/uk-UA.json b/apps/compliance-portal/src/_locales/uk-UA.json index fa23294e8..8af09adf1 100644 --- a/apps/compliance-portal/src/_locales/uk-UA.json +++ b/apps/compliance-portal/src/_locales/uk-UA.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/_locales/zh-CN.json b/apps/compliance-portal/src/_locales/zh-CN.json index b396b02f2..68c514577 100644 --- a/apps/compliance-portal/src/_locales/zh-CN.json +++ b/apps/compliance-portal/src/_locales/zh-CN.json @@ -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" + } } } diff --git a/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx b/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx new file mode 100644 index 000000000..78979bede --- /dev/null +++ b/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+ }> +
+

+ {t("locale.mismatch.message", { language: urlLabel })} +

+
+ + + setDismissed(true)} + > + + +
+
+
+
+ ); +} diff --git a/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx b/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx index 29fbd6800..238e03f2a 100644 --- a/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx +++ b/apps/compliance-portal/src/components/MailingListUpdateListItem/MailingListUpdateListItem.tsx @@ -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 ( - + } title={update.title} diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx index 424d62324..ae6c359e3 100644 --- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx +++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx @@ -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 + {t("home.recentUpdates.viewAll")} )} diff --git a/apps/compliance-portal/src/components/TopBar/LocaleSelect.tsx b/apps/compliance-portal/src/components/TopBar/LocaleSelect.tsx new file mode 100644 index 000000000..6095aa6ef --- /dev/null +++ b/apps/compliance-portal/src/components/TopBar/LocaleSelect.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + + ); +} diff --git a/apps/compliance-portal/src/components/TopBar/TopBar.tsx b/apps/compliance-portal/src/components/TopBar/TopBar.tsx index 1ba7ad163..085bb6e27 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBar.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBar.tsx @@ -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 (
- + diff --git a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx index ab58721df..4c62dd350 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx @@ -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) { +
+ +
{identity == null ? (