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 { DEFAULT_NAMESPACE } from "#/lib/i18n/backend";
import { import {
isUrlLocale, isUrlLocale,
matchNavigatorUrlLocale,
URL_LOCALE_LABELS, URL_LOCALE_LABELS,
urlLocaleToLanguage, urlLocaleToLanguage,
} from "#/lib/i18n/locale"; } from "#/lib/i18n/locale";
@@ -45,11 +46,12 @@ const localeMismatchBannerFragment = graphql`
`; `;
interface LocaleMismatchBannerProps { interface LocaleMismatchBannerProps {
identityKey: LocaleMismatchBanner_identity$key; identityKey: LocaleMismatchBanner_identity$key | null;
} }
// Full-bleed notice when the URL locale differs from the signed-in identity // Full-bleed notice when the URL locale differs from the preferred locale
// preference. Dismissed state is React-only (no localStorage/cookies). // (signed-in Identity.locale, or a matched navigator language for visitors).
// Dismissed state is React-only (no localStorage/cookies).
export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps) { export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const identity = useFragment(localeMismatchBannerFragment, identityKey); const identity = useFragment(localeMismatchBannerFragment, identityKey);
@@ -57,46 +59,51 @@ export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps)
const [changeLocale, isChanging] = useChangeLocale(); const [changeLocale, isChanging] = useChangeLocale();
const [updateLocale, isUpdating] = useUpdateLocale(); const [updateLocale, isUpdating] = useUpdateLocale();
const [dismissed, setDismissed] = useState(false); 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). // 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 preferredLocale = identity != null
const savedLanguage = savedLocale != null ? urlLocaleToLanguage(savedLocale) : null; ? (isUrlLocale(identity.locale) ? identity.locale : null)
const mismatched = savedLocale != null && savedLocale !== urlLocale; : 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 // Lag the mismatch flag so a transient desync during startTransition locale
// switches never paints the banner; a real mismatch still shows once settled. // switches never paints the banner; a real mismatch still shows once settled.
const deferredMismatched = useDeferredValue(mismatched); const deferredMismatched = useDeferredValue(mismatched);
const visible = !dismissed && mismatched && deferredMismatched; const visible = !dismissed && mismatched && deferredMismatched;
const canPersist = identity != null;
useEffect(() => { useEffect(() => {
if (!visible || savedLanguage == null) { if (!visible || preferredLanguage == null) {
return; return;
} }
if (i18n.hasResourceBundle(savedLanguage, DEFAULT_NAMESPACE)) { if (i18n.hasResourceBundle(preferredLanguage, DEFAULT_NAMESPACE)) {
return; return;
} }
let cancelled = false; let cancelled = false;
void i18n.loadLanguages(savedLanguage).then(() => { void i18n.loadLanguages(preferredLanguage).then(() => {
if (!cancelled) { if (!cancelled) {
setSavedCatalogTick(tick => tick + 1); setPreferredCatalogTick(tick => tick + 1);
} }
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [visible, savedLanguage, i18n]); }, [visible, preferredLanguage, i18n]);
if (!visible || savedLocale == null || savedLanguage == null) { if (!visible || preferredLocale == null || preferredLanguage == null) {
return null; return null;
} }
const urlLabel = URL_LOCALE_LABELS[urlLocale]; const urlLabel = URL_LOCALE_LABELS[urlLocale];
const savedLabel = URL_LOCALE_LABELS[savedLocale]; const preferredLabel = URL_LOCALE_LABELS[preferredLocale];
const busy = isChanging || isUpdating; const busy = isChanging || isUpdating;
const switchToSaved = () => { const switchToPreferred = () => {
void changeLocale(savedLocale, { persist: false }); void changeLocale(preferredLocale, { persist: false });
}; };
const adoptUrlLocale = () => { const adoptUrlLocale = () => {
@@ -116,28 +123,32 @@ export function LocaleMismatchBanner({ identityKey }: LocaleMismatchBannerProps)
)} )}
actions={( actions={(
<> <>
<Button {canPersist
size={1} ? (
variant="ghost" <Button
color="sky" size={1}
disabled={busy} variant="ghost"
onClick={adoptUrlLocale} color="sky"
> disabled={busy}
{t("locale.mismatch.useThis", { language: urlLabel })} onClick={adoptUrlLocale}
</Button> >
{t("locale.mismatch.useThis", { language: urlLabel })}
</Button>
)
: null}
<Button <Button
size={1} size={1}
variant="solid" variant="solid"
color="neutral" color="neutral"
highContrast highContrast
disabled={busy} disabled={busy}
onClick={switchToSaved} onClick={switchToPreferred}
> >
{t("locale.mismatch.switchToMine", { {t("locale.mismatch.switchToMine", {
language: savedLabel, language: preferredLabel,
// Label this action in the user's saved locale so it reads as // Label this action in the user's preferred locale so it reads as
// "switch back to my language", not the page they're visiting. // "switch back to my language", not the page they're visiting.
lng: savedLanguage, lng: preferredLanguage,
})} })}
</Button> </Button>
</> </>

View File

@@ -19,6 +19,7 @@
// SOFTWARE. // SOFTWARE.
import { import {
matchNavigatorLanguage,
resolveLanguage, resolveLanguage,
type SupportedLanguage, type SupportedLanguage,
} from "./resolveLanguage"; } from "./resolveLanguage";
@@ -98,6 +99,13 @@ export function resolveUrlLocale(): UrlLocale {
return languageToUrlLocale(resolveLanguage()); 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 { export function parseUrlLocale(value: string | undefined | null): UrlLocale | null {
if (!isUrlLocale(value)) { if (!isUrlLocale(value)) {
return null; 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 // 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 // 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 // fr-FR). Returns null when nothing matches — callers that need a guaranteed
// canonical supported tag here means i18next is never asked to load an // locale should use resolveLanguage() instead.
// unsupported locale; fallbackLng only has to cover individual missing keys. export function matchNavigatorLanguage(): SupportedLanguage | null {
export function resolveLanguage(): SupportedLanguage {
const candidates = navigator.languages?.length const candidates = navigator.languages?.length
? navigator.languages ? navigator.languages
: [navigator.language]; : [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} /> <TopBar queryKey={data} />
{data.viewer != null <LocaleMismatchBanner identityKey={data.viewer ?? null} />
? <LocaleMismatchBanner identityKey={data.viewer} />
: null}
{data.viewer != null && data.currentCompliancePortal != null {data.viewer != null && data.currentCompliancePortal != null
? <UnsignedNDABanner compliancePortalKey={data.currentCompliancePortal} /> ? <UnsignedNDABanner compliancePortalKey={data.currentCompliancePortal} />
: null} : null}