diff --git a/apps/compliance-portal/src/components/LocaleMismatchBanner/LocaleMismatchBanner.tsx b/apps/compliance-portal/src/components/LocaleMismatchBanner/LocaleMismatchBanner.tsx
index e34281fe2..27203225d 100644
--- a/apps/compliance-portal/src/components/LocaleMismatchBanner/LocaleMismatchBanner.tsx
+++ b/apps/compliance-portal/src/components/LocaleMismatchBanner/LocaleMismatchBanner.tsx
@@ -29,6 +29,7 @@ import { Banner } from "#/components/Banner/Banner";
import { DEFAULT_NAMESPACE } from "#/lib/i18n/backend";
import {
isUrlLocale,
+ matchNavigatorUrlLocale,
URL_LOCALE_LABELS,
urlLocaleToLanguage,
} from "#/lib/i18n/locale";
@@ -45,11 +46,12 @@ const localeMismatchBannerFragment = graphql`
`;
interface LocaleMismatchBannerProps {
- identityKey: LocaleMismatchBanner_identity$key;
+ identityKey: LocaleMismatchBanner_identity$key | null;
}
-// Full-bleed notice when the URL locale differs from the signed-in identity
-// preference. Dismissed state is React-only (no localStorage/cookies).
+// Full-bleed notice when the URL locale differs from the preferred locale
+// (signed-in Identity.locale, or a matched navigator language for visitors).
+// Dismissed state is React-only (no localStorage/cookies).
export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps) {
const { t, i18n } = useTranslation();
const identity = useFragment(localeMismatchBannerFragment, identityKey);
@@ -57,46 +59,51 @@ export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps)
const [changeLocale, isChanging] = useChangeLocale();
const [updateLocale, isUpdating] = useUpdateLocale();
const [dismissed, setDismissed] = useState(false);
- // Bumps after the identity-locale catalog loads so the switch button can
+ // Bumps after the preferred-locale catalog loads so the switch button can
// re-render in that language (it may not be the active i18n language).
- const [, setSavedCatalogTick] = useState(0);
+ const [, setPreferredCatalogTick] = useState(0);
- const savedLocale = isUrlLocale(identity.locale) ? identity.locale : null;
- const savedLanguage = savedLocale != null ? urlLocaleToLanguage(savedLocale) : null;
- const mismatched = savedLocale != null && savedLocale !== urlLocale;
+ const preferredLocale = identity != null
+ ? (isUrlLocale(identity.locale) ? identity.locale : null)
+ : matchNavigatorUrlLocale();
+ const preferredLanguage = preferredLocale != null
+ ? urlLocaleToLanguage(preferredLocale)
+ : null;
+ const mismatched = preferredLocale != null && preferredLocale !== urlLocale;
// Lag the mismatch flag so a transient desync during startTransition locale
// switches never paints the banner; a real mismatch still shows once settled.
const deferredMismatched = useDeferredValue(mismatched);
const visible = !dismissed && mismatched && deferredMismatched;
+ const canPersist = identity != null;
useEffect(() => {
- if (!visible || savedLanguage == null) {
+ if (!visible || preferredLanguage == null) {
return;
}
- if (i18n.hasResourceBundle(savedLanguage, DEFAULT_NAMESPACE)) {
+ if (i18n.hasResourceBundle(preferredLanguage, DEFAULT_NAMESPACE)) {
return;
}
let cancelled = false;
- void i18n.loadLanguages(savedLanguage).then(() => {
+ void i18n.loadLanguages(preferredLanguage).then(() => {
if (!cancelled) {
- setSavedCatalogTick(tick => tick + 1);
+ setPreferredCatalogTick(tick => tick + 1);
}
});
return () => {
cancelled = true;
};
- }, [visible, savedLanguage, i18n]);
+ }, [visible, preferredLanguage, i18n]);
- if (!visible || savedLocale == null || savedLanguage == null) {
+ if (!visible || preferredLocale == null || preferredLanguage == null) {
return null;
}
const urlLabel = URL_LOCALE_LABELS[urlLocale];
- const savedLabel = URL_LOCALE_LABELS[savedLocale];
+ const preferredLabel = URL_LOCALE_LABELS[preferredLocale];
const busy = isChanging || isUpdating;
- const switchToSaved = () => {
- void changeLocale(savedLocale, { persist: false });
+ const switchToPreferred = () => {
+ void changeLocale(preferredLocale, { persist: false });
};
const adoptUrlLocale = () => {
@@ -116,28 +123,32 @@ export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps)
)}
actions={(
<>
-
+ {canPersist
+ ? (
+
+ )
+ : null}
>
diff --git a/apps/compliance-portal/src/lib/i18n/locale.ts b/apps/compliance-portal/src/lib/i18n/locale.ts
index 92a6e1096..3648bcfa5 100644
--- a/apps/compliance-portal/src/lib/i18n/locale.ts
+++ b/apps/compliance-portal/src/lib/i18n/locale.ts
@@ -19,6 +19,7 @@
// SOFTWARE.
import {
+ matchNavigatorLanguage,
resolveLanguage,
type SupportedLanguage,
} from "./resolveLanguage";
@@ -98,6 +99,13 @@ export function resolveUrlLocale(): UrlLocale {
return languageToUrlLocale(resolveLanguage());
}
+// Navigator match with no en fallback — null when the browser languages do
+// not include any supported prefix.
+export function matchNavigatorUrlLocale(): UrlLocale | null {
+ const language = matchNavigatorLanguage();
+ return language != null ? languageToUrlLocale(language) : null;
+}
+
export function parseUrlLocale(value: string | undefined | null): UrlLocale | null {
if (!isUrlLocale(value)) {
return null;
diff --git a/apps/compliance-portal/src/lib/i18n/resolveLanguage.ts b/apps/compliance-portal/src/lib/i18n/resolveLanguage.ts
index 85388ba91..af973eb5d 100644
--- a/apps/compliance-portal/src/lib/i18n/resolveLanguage.ts
+++ b/apps/compliance-portal/src/lib/i18n/resolveLanguage.ts
@@ -56,10 +56,9 @@ const PREFIX_TO_LANGUAGE: Record = {
// Collapse the browser's preferred languages to one of our supported locales
// by matching the two-letter language prefix (e.g. any "fr*" tag maps to
-// fr-FR). en-US is the ultimate fallback when nothing matches. Resolving to a
-// canonical supported tag here means i18next is never asked to load an
-// unsupported locale; fallbackLng only has to cover individual missing keys.
-export function resolveLanguage(): SupportedLanguage {
+// fr-FR). Returns null when nothing matches — callers that need a guaranteed
+// locale should use resolveLanguage() instead.
+export function matchNavigatorLanguage(): SupportedLanguage | null {
const candidates = navigator.languages?.length
? navigator.languages
: [navigator.language];
@@ -72,5 +71,12 @@ export function resolveLanguage(): SupportedLanguage {
}
}
- return "en-US";
+ return null;
+}
+
+// Like matchNavigatorLanguage, but falls back to en-US when nothing matches.
+// Resolving to a canonical supported tag here means i18next is never asked to
+// load an unsupported locale; fallbackLng only has to cover missing keys.
+export function resolveLanguage(): SupportedLanguage {
+ return matchNavigatorLanguage() ?? "en-US";
}
diff --git a/apps/compliance-portal/src/pages/MainLayout.tsx b/apps/compliance-portal/src/pages/MainLayout.tsx
index 82a69a6a7..df5838343 100644
--- a/apps/compliance-portal/src/pages/MainLayout.tsx
+++ b/apps/compliance-portal/src/pages/MainLayout.tsx
@@ -75,9 +75,7 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
}
>
- {data.viewer != null
- ?
- : null}
+
{data.viewer != null && data.currentCompliancePortal != null
?
: null}