import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from 'react'; import i18n from '../i18n'; import { useQueryClient } from '@tanstack/react-query'; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── /** * Language code — any string is allowed so consumers can extend with their own * locales (e.g. `'fr'`, `'de'`, `'ja'`). The library ships with built-in * support for `'pt-BR'`, `'en'`, and `'es'`, but those are recommendations not * restrictions. */ export type Language = string; /** * Descriptor for a single language available in the picker. */ export interface LanguageDefinition { /** ISO/BCP-47 code stored in localStorage and passed to i18n.changeLanguage() */ code: Language; /** Full display label shown in the LanguageSelector dropdown */ label: string; /** Short label shown in `variant="minimal"` (e.g. "PT", "EN") */ shortLabel?: string; /** Optional translation JSON. When provided, it is registered with i18next * on mount so dynamically added locales are loaded automatically. * Use a plain object matching your `pt-BR.json` shape. */ resources?: Record; } interface LanguageContextType { /** Currently active language code */ language: Language; /** Change the active language (persists + updates i18n + invalidates queries) */ setLanguage: (language: Language) => void; /** All languages registered for this app */ availableLanguages: LanguageDefinition[]; /** True when only a single language is configured — UI can hide selectors */ isMonolingual: boolean; } // ───────────────────────────────────────────────────────────────────────────── // Defaults // ───────────────────────────────────────────────────────────────────────────── /** * Default language set when no `availableLanguages` prop is provided. * These are the locales the library ships with out-of-the-box. */ export const DEFAULT_LANGUAGES: LanguageDefinition[] = [ { code: 'pt-BR', label: 'Português (BR)', shortLabel: 'PT' }, { code: 'en', label: 'English', shortLabel: 'EN' }, { code: 'es', label: 'Español', shortLabel: 'ES' }, ]; const STORAGE_KEY = 'xertica_language'; // Maps legacy localStorage values (from older versions) to canonical codes. const LEGACY_LANGUAGE_MAP: Record = { PT: 'pt-BR', pt: 'pt-BR', EN: 'en', 'en-US': 'en', ES: 'es', }; // ───────────────────────────────────────────────────────────────────────────── // Context // ───────────────────────────────────────────────────────────────────────────── const LanguageContext = createContext(undefined); // ───────────────────────────────────────────────────────────────────────────── // Provider // ───────────────────────────────────────────────────────────────────────────── export interface LanguageProviderProps { children: ReactNode; /** * List of languages available to this app. The first item is the default * when no saved preference exists. Pass a single-item array to lock the * app to one language (the LanguageSelector will hide itself automatically). * Defaults to `DEFAULT_LANGUAGES` (pt-BR, en, es) when omitted. * * @example * ```tsx * // Monolingual English app * * ``` * * @example * ```tsx * // Add French alongside the built-in languages * import fr from './locales/fr.json'; * * * ``` */ availableLanguages?: LanguageDefinition[]; /** * Override the default language. Falls back to the first item in * `availableLanguages` when omitted. */ defaultLanguage?: Language; } export function LanguageProvider({ children, availableLanguages = DEFAULT_LANGUAGES, defaultLanguage, }: LanguageProviderProps) { // Hard guard: empty array is almost always a consumer mistake. Throwing here // surfaces the bug at provider mount instead of letting the app silently fall // back to DEFAULT_LANGUAGES (which would, in turn, surprise monolingual apps // that meant to pass `[{code:'en',...}]` but accidentally passed `[]`). if (availableLanguages.length === 0) { throw new Error( 'LanguageProvider: `availableLanguages` cannot be empty. Pass at least one ' + 'LanguageDefinition or omit the prop to use DEFAULT_LANGUAGES.' ); } const languages = availableLanguages; // useQueryClient() works because LanguageProvider is always rendered inside // (both in App.tsx and via XerticaProvider in templates). const queryClient = useQueryClient(); // Stable "signature" of the configured languages — string of comma-joined // codes. This prevents the effect below (and downstream `useMemo`) from // firing every render when a consumer passes `availableLanguages={[...]}` // inline (a fresh array reference on every render). const languageCodesKey = useMemo(() => languages.map(l => l.code).join(','), [languages]); // Register any locale `resources` carried by language definitions. This // allows consumers to ship new locales without editing `i18n.ts`. useEffect(() => { for (const lang of languages) { if (lang.resources && !i18n.hasResourceBundle(lang.code, 'translation')) { i18n.addResourceBundle(lang.code, 'translation', lang.resources, true, true); } } // `languageCodesKey` is a stable string signature; only re-runs when the // set of configured language codes actually changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [languageCodesKey]); const resolvedDefault = defaultLanguage ?? languages[0].code; const [language, setLanguageState] = useState(() => { const saved = typeof window !== 'undefined' ? window.localStorage?.getItem(STORAGE_KEY) : null; // Direct match against configured languages if (saved && languages.some(l => l.code === saved)) { return saved; } // Legacy migration if (saved && LEGACY_LANGUAGE_MAP[saved]) { const migrated = LEGACY_LANGUAGE_MAP[saved]; if (languages.some(l => l.code === migrated)) { return migrated; } } return resolvedDefault; }); /** Change language: persists to localStorage, updates i18next, and invalidates * the React Query cache so any query whose result contains translated strings * is refetched in the new language on next render. * * Components using `useTranslation()` already update instantly (i18next notifies * them). Queries using `useFeatureCards()` / `useAssistantConfig()` etc. also * update instantly because language is included in their queryKey — switching * language means a new key → cache miss → immediate refetch. * * Wrapped in `useCallback` so that consumers destructuring `setLanguage` and * passing it into effect dependency arrays get a stable reference. */ const setLanguage = useCallback( (lang: Language) => { // Silently no-op if the requested language is not registered. if (!languages.some(l => l.code === lang)) { if (typeof console !== 'undefined') { console.warn( `[LanguageProvider] Language "${lang}" is not in availableLanguages — ignoring.` ); } return; } setLanguageState(lang); window.localStorage?.setItem(STORAGE_KEY, lang); i18n.changeLanguage(lang); // Defensive invalidation: refetches any query that didn't include language // in its queryKey and would otherwise serve stale translated strings. queryClient.invalidateQueries(); }, // Depend on the stable codes signature instead of the `languages` array // reference so the callback identity remains stable across inline-prop renders. // eslint-disable-next-line react-hooks/exhaustive-deps [languageCodesKey, queryClient] ); // Sync i18next on mount in case the page refreshed with a saved language useEffect(() => { if (i18n.language !== language) { i18n.changeLanguage(language); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // mount-only — i18n.ts already reads localStorage for the initial lng const value = useMemo( () => ({ language, setLanguage, availableLanguages: languages, isMonolingual: languages.length <= 1, }), // `setLanguage` is stable thanks to useCallback above; `languages` is gated // by `languageCodesKey` so that inline-array consumers don't thrash the memo. // eslint-disable-next-line react-hooks/exhaustive-deps [language, setLanguage, languageCodesKey] ); return {children}; } // ───────────────────────────────────────────────────────────────────────────── // Hook // ───────────────────────────────────────────────────────────────────────────── export function useLanguage() { const context = useContext(LanguageContext); if (context === undefined) { throw new Error('useLanguage must be used within a LanguageProvider'); } return context; }