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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user