diff --git a/apps/compliance-portal/src/lib/i18n/useEnsureViewerLocale.ts b/apps/compliance-portal/src/lib/i18n/useEnsureViewerLocale.ts new file mode 100644 index 000000000..c4b632676 --- /dev/null +++ b/apps/compliance-portal/src/lib/i18n/useEnsureViewerLocale.ts @@ -0,0 +1,55 @@ +// 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 { useEffect, useRef } from "react"; +import { graphql, useFragment } from "react-relay"; + +import type { useEnsureViewerLocale_identity$key } from "./__generated__/useEnsureViewerLocale_identity.graphql"; +import { useLocale } from "./useLocale"; +import { useUpdateLocale } from "./useUpdateLocale"; + +const ensureViewerLocaleFragment = graphql` + fragment useEnsureViewerLocale_identity on Identity { + locale + } +`; + +// Seeds Identity.locale once when it is still null, using the current URL +// locale. Never overwrites an existing preference (shared-link safe). +export function useEnsureViewerLocale( + identityKey: useEnsureViewerLocale_identity$key | null, +) { + const identity = useFragment(ensureViewerLocaleFragment, identityKey); + const urlLocale = useLocale(); + const [updateLocale] = useUpdateLocale(); + const firedRef = useRef(false); + + useEffect(() => { + if (identity == null || identity.locale != null || firedRef.current) { + return; + } + + firedRef.current = true; + void updateLocale(urlLocale, { errorToast: false }).catch(() => { + // Allow a later mount / navigation to retry if the seed failed. + firedRef.current = false; + }); + }, [identity, updateLocale, urlLocale]); +} diff --git a/apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts b/apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts index 97cecee54..6856bbe06 100644 --- a/apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts +++ b/apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import type { MutationFeedback } from "@probo/relay"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { graphql } from "react-relay"; @@ -45,10 +46,16 @@ export function useUpdateLocale() { errorToast: t("locale.updateFailed"), }); - const updateLocale = useCallback(async (locale: UrlLocale) => { - await commit({ - variables: { input: { locale } }, - }); + const updateLocale = useCallback(async ( + locale: UrlLocale, + feedback?: MutationFeedback, + ) => { + await commit( + { + variables: { input: { locale } }, + }, + feedback, + ); }, [commit]); return [updateLocale, isUpdating] as const; diff --git a/apps/compliance-portal/src/pages/MainLayout.tsx b/apps/compliance-portal/src/pages/MainLayout.tsx index e89885df2..152d81f23 100644 --- a/apps/compliance-portal/src/pages/MainLayout.tsx +++ b/apps/compliance-portal/src/pages/MainLayout.tsx @@ -27,6 +27,7 @@ import { LocaleMismatchCallout } from "#/components/LocaleMismatchCallout"; import { PoweredBy } from "#/components/PoweredBy/PoweredBy"; import { TopBar } from "#/components/TopBar/TopBar"; import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest"; +import { useEnsureViewerLocale } from "#/lib/i18n/useEnsureViewerLocale"; import { SubscribeDialogProvider } from "#/lib/mailingList/SubscribeDialogProvider"; import type { MainLayoutQuery } from "./__generated__/MainLayoutQuery.graphql"; @@ -36,6 +37,7 @@ export const mainLayoutQuery = graphql` viewer { __typename ...LocaleMismatchCallout_identity + ...useEnsureViewerLocale_identity } ...TopBar_query ...SubscribeDialogProvider_query @@ -53,6 +55,8 @@ export function MainLayout({ queryRef }: MainLayoutProps) { // Resume a deferred "request access" once the user lands back authenticated. useResumeAccessRequest(data.viewer != null); + // First signed-in visit: seed Identity.locale from the URL when still null. + useEnsureViewerLocale(data.viewer ?? null); // Document viewer fills the viewport under the TopBar and scrolls its own // stage; every other page uses normal document flow so the footer sits after