@transcend-io/consent-manager-ui
Version:
Transcend Consent Manager reference consent UI
312 lines (289 loc) • 10.6 kB
text/typescript
import { useCallback, useEffect, useState } from 'preact/hooks';
import {
ConsentManagerLanguageKey,
LanguageKey,
TranslatedMessages,
Translations,
} from '@transcend-io/internationalization';
import { settings } from '../settings';
import { substituteHtml } from '../utils/substitute-html';
import { invertSafe } from '@transcend-io/type-utils';
export const loadedTranslations: Translations = Object.create(null);
/**
* Mapping of browser locale to AWS base translation key
*
* TODO: https://transcend.height.app/T-39777
* This is here for a quick fix of our CM UI translation logic. Copied from the monorepo's extract_intl.ts
* This should be removed ASAP (it should be in the intl repo and imported, not in here or extract_intl.ts)
*/
export const TRANSLATE_LOCALE = {
[]: 'es',
[]: 'nl',
[]: 'nl',
[]: 'es-MX',
// This is a hack to fix the fact that we don't have a LanguageKey -> BrowserLanguageKey mapping
'es-MX': 'es-MX',
[]: 'zh-TW',
[]: 'af',
[]: 'ar',
[]: 'en',
[]: 'fr',
[]: 'es',
[]: 'de',
[]: 'it',
[]: 'ja',
[]: 'ru',
[]: 'ar',
[]: 'fr',
[]: 'de',
[]: 'it',
[]: 'bg',
[]: 'zh',
[]: 'hr',
[]: 'cs',
[]: 'da',
[]: 'en',
[]: 'fi',
[]: 'el',
[]: 'hi',
[]: 'hu',
[]: 'id',
[]: 'ja',
[]: 'ko',
[]: 'lt',
[]: 'ms',
[]: 'no',
[]: 'pl',
[]: 'pt',
[]: 'pt',
[]: 'ro',
[]: 'ru',
[]: 'sr',
[]: 'sv',
[]: 'ta',
[]: 'th',
[]: 'tr',
[]: 'uk',
[]: 'vi',
[]: 'en',
[]: 'en',
[]: 'fr',
[]: 'en',
[]: 'en',
[]: 'en',
[]: 'de',
[]: 'de',
[]: 'it',
[]: 'fr',
[]: 'he',
[]: 'en',
[]: 'et',
[]: 'is',
[]: 'lv',
[]: 'mt',
[]: 'sk',
[]: 'sl',
[]: 'mr',
[]: 'en',
} as unknown as { [k in LanguageKey]: string };
/** Mapping of AWS base translation keys to list of browser locales that should use them */
export const INVERTED_TRANSLATE_LOCALE = invertSafe(TRANSLATE_LOCALE);
const getDuplicativeLocalizations = (lang: LanguageKey): LanguageKey[] =>
INVERTED_TRANSLATE_LOCALE[TRANSLATE_LOCALE[lang]];
/**
* Detect user-preferred languages from the user agent
*
* @returns an ordered list of preferred languages in BCP47 format
*/
export function getUserLanguages(): readonly string[] {
return navigator.languages && navigator.languages.length
? navigator.languages
: [navigator.language];
}
/**
* Get all potential sub-languages from a given ISO 639-1 or BCP47 language code
*
* @param langCode - ISO 639-1 or BCP47 format language code
* @returns Array of potential sub-languages, sorted by most to least specific
*/
const getAllSubLanguages = (langCode: ConsentManagerLanguageKey): string[] =>
langCode
.toLowerCase()
.split('-')
.flatMap((_, i, tags) => tags.slice(0, i + 1).join('-'))
.reverse();
/**
* Match preferred language list against a supported languages list
*
* @param preferred - Sorted language list in order of most preferable to least preferable
* @param supported - List of supported languages to match from
* @returns Preferred languages (including sub-language matches) that match supported languages list
*/
export const matchLanguages = (
preferred: ConsentManagerLanguageKey[],
supported: ConsentManagerLanguageKey[],
): string[] => {
const matches = new Set<string>();
const allSupportedLanguages = supported.flatMap(getAllSubLanguages);
return preferred.flatMap(getAllSubLanguages).filter((language) => {
if (!allSupportedLanguages.includes(language)) {
return false;
}
const unique = !matches.has(language);
if (unique) {
matches.add(language);
}
return unique;
});
};
/**
* Get nearest matching language from a list of supported languages
*
* @param preferred - Sorted language list in order of most preferable to least preferable
* @param supported - List of supported languages to match from
* @returns Nearest supported language, sorted by preferred language list
*/
export const getNearestSupportedLanguage = (
preferred: readonly string[],
supported: ConsentManagerLanguageKey[],
): ConsentManagerLanguageKey | undefined =>
supported.find((language) =>
getAllSubLanguages(language).some((lang) =>
preferred.some((preferredLang) => preferredLang.toLowerCase() === lang),
),
);
/**
* Sorts the supported languages by the user's preferences
*
* @param languages - an object of translations
* @returns a list of language keys, sorted
*/
export const sortSupportedLanguagesByPreference = (
languages: ConsentManagerLanguageKey[],
): ConsentManagerLanguageKey[] =>
languages.sort((a, b) => {
const preferredLanguagesFull = getUserLanguages();
// Only use first token e.g. fr-CA => fr, and remove dupes e.g. [en-US, en] => [en], by casting to set
const preferredLanguagesShort = [
...new Set(preferredLanguagesFull.map((l) => l.split('-')[0])),
];
const rank = (l: string): number =>
preferredLanguagesFull.includes(l)
? preferredLanguagesFull.indexOf(l)
: preferredLanguagesShort.includes(l)
? preferredLanguagesShort.indexOf(l)
: Infinity;
return rank(a) - rank(b);
});
/**
* Picks a default language for the user
*
* @param supportedLanguages - Set of supported languages
* @returns the language key of the best default language for this user
*/
export function pickDefaultLanguage(
supportedLanguages: ConsentManagerLanguageKey[],
): ConsentManagerLanguageKey {
if (settings.locale && supportedLanguages.includes(settings.locale)) {
return settings.locale;
}
const preferredLanguages = getUserLanguages();
/* We should refactor this ASAP TODO: https://transcend.height.app/T-39777
* Extend supportedLanguages to include locales that we consider equivalent
* e.g. instead of just having en, include en-US, en-GB, en-AU, etc
*/
const extendedSupportedLanguages = supportedLanguages
.map((lang: ConsentManagerLanguageKey) => getDuplicativeLocalizations(lang))
.flat() as ConsentManagerLanguageKey[];
const nearestExtendedLanguage =
getNearestSupportedLanguage(
preferredLanguages,
sortSupportedLanguagesByPreference(extendedSupportedLanguages),
) || ConsentManagerLanguageKey.En;
let nearestTranslation = nearestExtendedLanguage;
if (!supportedLanguages.includes(nearestTranslation)) {
nearestTranslation = getDuplicativeLocalizations(nearestTranslation).find(
(lang) => supportedLanguages.includes(lang as ConsentManagerLanguageKey),
) as ConsentManagerLanguageKey;
}
return nearestTranslation;
}
/**
* Fetch message translations
*
* @param translationsLocation - Base path to fetching messages
* @param language - Language to fetch
* @returns The translations
*/
export const getTranslations = async (
translationsLocation: string,
language: ConsentManagerLanguageKey,
): Promise<TranslatedMessages> => {
loadedTranslations[language] ??= await (async () => {
const pathToFetch = `${translationsLocation}/${language}.json`;
const response = await fetch(pathToFetch);
if (!response.ok) {
throw new Error(`Failed to load translations for language ${language}`);
}
return response.json();
})();
return loadedTranslations[language];
};
/**
* Sets the language to use in translator
*
* @param options - Options
* @returns the language and a change language callback
*/
export function useLanguage({
supportedLanguages,
translationsLocation,
}: {
/** Set of supported languages */
supportedLanguages: ConsentManagerLanguageKey[];
/** Base path to fetching messages */
translationsLocation: string;
}): {
/** The language in use */
language: ConsentManagerLanguageKey;
/** A change language callback */
handleChangeLanguage: (language: ConsentManagerLanguageKey) => void;
/** Message translations */
messages: TranslatedMessages | undefined;
/** HTML opening/closing tab variables */
htmlTagVariables: Record<string, string>;
} {
// The current language
const [language, setLanguage] = useState<ConsentManagerLanguageKey>(() =>
// choose a default language based on the browser selected
pickDefaultLanguage(supportedLanguages),
);
// Hold the translations for that language (fetched async)
const [messages, setMessages] = useState<TranslatedMessages | undefined>();
// Store the HTML opening/closing tags we need to replace our tag variables with
const [htmlTagVariables, setHtmlTagVariables] = useState<
Record<string, string>
>({});
// Load the default translations
useEffect(() => {
getTranslations(translationsLocation, language).then((messages) => {
// Replace raw HTML tags with variables bc raw HTML causes parsing errors
const { substitutedMessages, tagVariables } = substituteHtml(messages);
setHtmlTagVariables(tagVariables);
setMessages(substitutedMessages);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChangeLanguage = useCallback(
async (language: ConsentManagerLanguageKey) => {
const newMessages = await getTranslations(translationsLocation, language);
// Replace raw HTML tags with variables bc raw HTML causes parsing errors
const { substitutedMessages, tagVariables } = substituteHtml(newMessages);
setMessages(substitutedMessages);
setHtmlTagVariables(tagVariables);
setLanguage(language);
},
[],
);
return { language, handleChangeLanguage, messages, htmlTagVariables };
}