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}