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