Show locale mismatch banner for visitors

Visitors have no Identity.locale, so the banner now falls
back to a supported navigator language match and omits the
persist action. Unsupported browser languages stay silent
instead of pretending English is preferred.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-31 11:53:58 +02:00
parent 2f81b69ad1
commit afdf0aacc7
4 changed files with 61 additions and 38 deletions

View File

@@ -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={(
<>
<Button
size={1}
variant="ghost"
color="sky"
disabled={busy}
onClick={adoptUrlLocale}
>
{t("locale.mismatch.useThis", { language: urlLabel })}
</Button>
{canPersist
? (
<Button
size={1}
variant="ghost"
color="sky"
disabled={busy}
onClick={adoptUrlLocale}
>
{t("locale.mismatch.useThis", { language: urlLabel })}
</Button>
)
: null}
<Button
size={1}
variant="solid"
color="neutral"
highContrast
disabled={busy}
onClick={switchToSaved}
onClick={switchToPreferred}
>
{t("locale.mismatch.switchToMine", {
language: savedLabel,
// Label this action in the user's saved locale so it reads as
language: preferredLabel,
// Label this action in the user's preferred locale so it reads as
// "switch back to my language", not the page they're visiting.
lng: savedLanguage,
lng: preferredLanguage,
})}
</Button>
</>

View File

@@ -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;

View File

@@ -56,10 +56,9 @@ const PREFIX_TO_LANGUAGE: Record<string, SupportedLanguage> = {
// 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";
}

View File

@@ -75,9 +75,7 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
}
>
<TopBar queryKey={data} />
{data.viewer != null
? <LocaleMismatchBanner identityKey={data.viewer} />
: null}
<LocaleMismatchBanner identityKey={data.viewer ?? null} />
{data.viewer != null && data.currentCompliancePortal != null
? <UnsignedNDABanner compliancePortalKey={data.currentCompliancePortal} />
: null}